offline_protocol/events.rs
1//! Event types and callbacks.
2
3use offline_protocol_core::{ContentType, MediaMetadata, Message, MessageId};
4use offline_protocol_services::ServiceEvent;
5use offline_protocol_transport::TransportType;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9use std::sync::Arc;
10
11/// Presence status for a peer.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum PresenceStatus {
15 /// Peer is online and available.
16 Online,
17 /// Peer is away / idle.
18 Away,
19 /// Peer is explicitly offline.
20 Offline,
21}
22
23/// Where a `PresenceUpdated` event came from.
24///
25/// Apps that render relay-style presence UI (e.g. a direct-chat header's
26/// "Online" / "Last seen …") should filter on `Internet` so a nearby peer's
27/// self-report can't flip a header that is defined as relay-observed.
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum PresenceSource {
31 /// Relay-observed presence: the relay's authoritative answer to
32 /// `CheckPresence` (`PresenceStatus` / `PresenceStatusWithLastSeen`),
33 /// or relay-derived reachability — the platform bridges also report
34 /// `offline` here when a relay `DeliveryError` names the recipient
35 /// unreachable, so a failed send can produce an `Internet`-sourced
36 /// offline event without any explicit presence query.
37 Internet,
38 /// Gateway-observed presence over the Reticulum daemon contract: a
39 /// `PresenceStatus` answer from the gateway this device is attached to,
40 /// whether solicited by the SDK's watchlist or pushed when a watched
41 /// peer's state changes.
42 ///
43 /// Named for the carrier rather than for "gateway", because that is what
44 /// [`Self::Internet`] names too: the relay is a gateway, and an app that
45 /// wants to know which carrier answered has to be able to tell them
46 /// apart. A claim from either has the same standing and the same decay.
47 Reticulum,
48 /// A peer-sent `__PRESENCE__` self-report. Transport-agnostic: it may
49 /// arrive over BLE, WiFi Direct, or even relay-forwarded frames — hence
50 /// "peer", not "mesh". Default for deserializing legacy events that
51 /// predate the field.
52 #[default]
53 Peer,
54}
55
56/// Event callback type for handling protocol events.
57pub type EventCallback = Arc<dyn Fn(Event) + Send + Sync>;
58
59/// Machine-readable reason taxonomy for welcome delivery failures.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
62pub enum WelcomeReasonCode {
63 /// No transport was available or all transport sends failed.
64 TransportUnavailable,
65 /// The transport is up but this specific peer is unreachable on it
66 /// (e.g. the internet relay reported the recipient offline). Treated as
67 /// per-peer no-carrier: the welcome parks instead of aging.
68 PeerUnreachable,
69 /// Peer was disconnected or became unavailable during send.
70 PeerDisconnected,
71 /// Send operation timed out.
72 Timeout,
73 /// Local/internal error prevented send.
74 InternalError,
75 /// Retry budget or lifecycle TTL was exhausted.
76 RetryExhausted,
77}
78
79impl WelcomeReasonCode {
80 /// Returns the stable machine-readable reason code.
81 pub fn as_str(&self) -> &'static str {
82 match self {
83 Self::TransportUnavailable => "TRANSPORT_UNAVAILABLE",
84 Self::PeerUnreachable => "PEER_UNREACHABLE",
85 Self::PeerDisconnected => "PEER_DISCONNECTED",
86 Self::Timeout => "TIMEOUT",
87 Self::InternalError => "INTERNAL_ERROR",
88 Self::RetryExhausted => "RETRY_EXHAUSTED",
89 }
90 }
91}
92
93/// Machine-readable taxonomy for [`Event::SecurityWarning`], so consumers can
94/// branch on a stable code instead of matching the human-readable `reason`
95/// string (which is for logs/UI and may change between versions).
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
98pub enum SecurityWarningCode {
99 /// A control message's signature verified, but the key that signed it does
100 /// not derive to the address the frame claims to come from.
101 ///
102 /// There is no benign reading. An address *is* the hash of its identity
103 /// key, so a legitimate peer's frames always re-derive to their own
104 /// address: a rotated key is a *different address*, i.e. a different peer,
105 /// not the same peer with new bytes. This is either an impersonation
106 /// attempt or a peer running a build with a broken derivation.
107 ///
108 /// Replaces `TOFU_KEY_MISMATCH`, whose remedy (`resetTofuForPeer`) no longer
109 /// exists because there is no pin to reset.
110 SenderAddressMismatch,
111 /// A control message's signed sender did not match its transport peer
112 /// identity.
113 TransportIdentityMismatch,
114 /// A control message's signature failed verification (invalid signature or
115 /// malformed metadata).
116 ControlSignatureInvalid,
117 /// An unsigned control message was rejected.
118 ///
119 /// All security-gated control traffic must be signed by the key its sender
120 /// address derives from. This is unconditional: it does not depend on
121 /// `require_transport_identity`, nor on whether the sender was seen before.
122 /// A peer that will not sign is making a claim that cannot be checked.
123 UnsignedControlRejected,
124 /// An encrypted media chunk's MLS group did not match the 1:1 session
125 /// group of the claimed wire sender — a valid ciphertext (from some
126 /// session) delivered under a different sender's name is a media
127 /// forgery/misattribution attempt.
128 MediaSenderGroupMismatch,
129 /// An outbound message left this node as plaintext because encryption is
130 /// disabled or MLS is uninitialized while `require_encryption` is `false`
131 /// (an explicit opt-out — the default fails closed). Emitted at most once
132 /// per peer per protocol instance.
133 PlaintextSend,
134 /// Inbound plaintext content (text or legacy media) was rejected by the
135 /// encryption policy: either `require_encryption` is enabled, or a
136 /// confirmed MLS session exists with the claimed sender and plaintext
137 /// from that peer is a downgrade/forgery attempt (plaintext carries no
138 /// sender authentication). Emitted at most once per peer; the per-peer
139 /// tracking set is bounded, so after a flood of forged sender ids a
140 /// peer may warn again.
141 PlaintextReceiveRejected,
142 /// An encrypted 1:1 envelope named an MLS group that is not the session
143 /// slot shared with the claimed wire sender. The text-path counterpart to
144 /// [`Self::MediaSenderGroupMismatch`]: it catches the same misattribution
145 /// on paths where MLS cannot, because the envelope failed before it
146 /// authenticated anything.
147 SessionSenderGroupMismatch,
148 /// A 1:1 MLS session was torn down and re-advertised after an epoch
149 /// desync. Expected occasionally (a genuine fork heals this way), but the
150 /// triggering frame is not authenticated — see `schedule_session_rekey` —
151 /// so a sustained rate of these for one peer indicates injected frames
152 /// rather than a real fork.
153 SessionRekeyTriggered,
154 /// A Nostr key-package publication slot could not be refilled, so cold
155 /// first contact over Nostr is degraded or unavailable until it succeeds.
156 ///
157 /// Slots are consumed locally — an init key leaves provider storage only
158 /// when *this* node processes a Welcome built against it — so consumption
159 /// itself is normal traffic, and a stranger can drive it only by actually
160 /// establishing sessions with us. What this warns about is the refill
161 /// failing (MLS or storage error), which is the failure the publication
162 /// design must never absorb silently: a stale published record makes every
163 /// stranger who fetches it build a Welcome that can never be processed.
164 NostrKeyPackageSlotExhausted,
165 /// The per-peer key-package pool hit its ceiling, so a peer was advertised
166 /// an init key another peer already holds.
167 ///
168 /// The push path normally gives each peer its own single-use init key. Past
169 /// the ceiling it reuses one instead of growing the pool without bound —
170 /// weakening forward secrecy at session establishment (one compromised init
171 /// key opens every Welcome built against it) and re-introducing the race
172 /// where the second peer to use a package finds it already consumed. Both
173 /// clear as packages are consumed or expire.
174 ///
175 /// Reaching it means a device has accumulated unconsumed advertisements:
176 /// many peers met that never established a session. Sustained emission is
177 /// the signal that the ceiling, not peer churn, is now the binding
178 /// constraint.
179 PushKeyPackagePoolExhausted,
180 /// The relay acknowledged an address declaration naming an address that is
181 /// not this node's.
182 ///
183 /// There is no benign reading. The bridge declares `local_address()` and
184 /// signs a proof over a per-connection challenge; the relay verifies that
185 /// the declared address derives from the key that signed it, and echoes
186 /// back what it bound. So the echo can only differ if the relay bound
187 /// something other than what it verified — a broken or hostile relay, or a
188 /// frame injected into the socket.
189 ///
190 /// Nothing is torn down: a relay that controls the socket already controls
191 /// everything a local refusal could protect, so the mitigation is that the
192 /// signal is loud rather than that the connection dies. What it means in
193 /// practice is that this connection's frames are attributed to an identity
194 /// this node cannot prove, so receivers strict-matching the sender against
195 /// the transport identity will reject its security-gated control traffic.
196 RelayAddressBindingMismatch,
197 /// The relay refused this connection's address declaration, so it stays
198 /// attributed by account name.
199 ///
200 /// Operational degradation, not an attack signal: the refusal path is
201 /// deliberately non-fatal on both sides, and the connection keeps working
202 /// exactly as it did before addresses existed. What is lost is *new* MLS
203 /// session establishment over the relay — `__MLS_KEY_PKG__` and
204 /// `__MLS_WELCOME__` are security-gated, so a receiver that strict-matches
205 /// the account name against the `off1…` sender rejects them, while already
206 /// established sessions keep working because `__MLS_ENC__` is data-plane.
207 ///
208 /// The relay's own text is **not** carried on the event. It is
209 /// remote-chosen, and this event's `reason` is shipped verbatim by the
210 /// telemetry scrubber, so the wording stays bounded in the device log
211 /// while the code above is the classification. The causes it distinguishes
212 /// range from unusable key material to this socket having been displaced
213 /// by a newer login; none of them changes what an app should do. No retry
214 /// is attempted — the next reconnect declares from scratch.
215 RelayAddressDeclarationRefused,
216 /// A group leaf carries a credential its own signature key does not derive
217 /// to — an identity claimed inside a group without the key to prove it.
218 ///
219 /// There is no benign reading, and unlike most codes here it accuses
220 /// someone the user is already in a room with. An address *is* the hash of
221 /// its identity key, so every honest leaf reproduces its own credential;
222 /// producing one that does not means deliberately building a leaf around
223 /// someone else's name.
224 ///
225 /// `peer_id` is **who the finding concerns, not always who to blame**. On
226 /// the three refusal sites it is the peer that delivered the forgery — the
227 /// Welcome's inviter, or the sender of the frame carrying the commit — and
228 /// never the address the forged leaf claimed. That attribution is worth
229 /// something because it is proved independently: `__GRP_MLS_WELCOME__`,
230 /// `__GRP_MLS_COMMIT__` and `__MLS_WELCOME__` are all security-gated, so
231 /// the sender signed with the key its own address derives from. On the
232 /// fourth site there is no delivering peer at all and `peer_id` is this
233 /// device's own id.
234 ///
235 /// `reason` is diagnostic text, must not be parsed, and deliberately
236 /// carries **no identifier** — the impersonated address appears only in
237 /// this device's logs. Telemetry scrubbing hashes `peer_id` and passes
238 /// `reason` through verbatim, and the identity at stake belongs to a third
239 /// party who is not even part of the exchange.
240 ///
241 /// Emitted from four sites. The first three refuse a claim *arriving*, so
242 /// nothing is installed and the frame is dropped:
243 ///
244 /// 1. joining a group Welcome whose ratchet tree contains such a leaf (the
245 /// invite is declined outright);
246 /// 2. processing a membership commit that would install one (the commit is
247 /// not merged, and is never buffered for retry);
248 /// 3. joining a 1:1 session Welcome whose ratchet tree contains one —
249 /// including when we already hold a session with that peer, where the
250 /// refusal is non-destructive and the existing session stays live (so a
251 /// `secure_session_failed` alongside this does *not* mean the working
252 /// session ended).
253 ///
254 /// The fourth is different in kind and needs a different response:
255 ///
256 /// 4. a roster read skipping a leaf **already seated in local group
257 /// state**. No wire gate can have admitted it, so it arrived by a direct
258 /// write to this device's secure store or via a group joined by a build
259 /// predating those gates. Such a leaf is kept out of every roster and
260 /// cannot speak — but it holds live group secrets and reads everything
261 /// sent to the group, which no later refusal undoes. The remedy is to
262 /// abandon the group, not to evict a member of it.
263 ///
264 /// Apps should treat a group that produces this as untrusted for
265 /// attribution — a message shown as coming from a member is exactly what
266 /// the forged leaf was for.
267 GroupLeafIdentityUnproven,
268 /// A control frame was refused for what its signature says about *when*:
269 /// stamped further in the past than this node accepts, further ahead of
270 /// this node's clock than it accepts, or carrying the older signing
271 /// payload from a peer that has proved it can produce the newer one.
272 ///
273 /// The three share a code because they share a remedy, and the remedy is
274 /// the first thing to check: **this device's own clock**. The signed
275 /// timestamp is compared against it, so a device whose clock is wrong
276 /// refuses honest peers in bulk, and the event stream then looks like a
277 /// coordinated attack rather than a local fault. A run of these naming
278 /// *many different* peers is a clock; a run naming *one* peer while others
279 /// are fine is that peer, either replaying captures or running a broken
280 /// build.
281 ///
282 /// It is the fresh-frame counterpart to
283 /// [`Self::UnsignedControlRejected`]: both refuse a frame whose signature
284 /// proves less than this node requires, one for what it omits and one for
285 /// what it fails to state. Recovery of last resort is
286 /// `security.control_freshness_enforced = false`, which returns this node
287 /// to accepting frames of any age (see issue 403 for what that gives back
288 /// to an attacker).
289 StaleControlFrame,
290 /// A gateway echoed an `AddressDeclared` naming an address that is not
291 /// this device's.
292 ///
293 /// The gateway twin of [`Self::RelayAddressBindingMismatch`], and it has
294 /// the same absence of a benign reading: the gateway verifies that the
295 /// declared address derives from the key that signed the proof, so an
296 /// echo naming anything else means it bound what it did not verify. A
297 /// session attached under an address this device does not control is how
298 /// another device's inbound traffic gets drawn here, and how the presence
299 /// answers given about that address are poisoned.
300 ///
301 /// Reported and not acted on, for the reason the relay case gives: a
302 /// gateway that owns the socket already owns everything a local teardown
303 /// would protect. What the bridges do act on is narrower and is not this
304 /// signal: they refuse to offer a carrier whose session was never bound.
305 GatewayAddressBindingMismatch,
306 /// A gateway refused this connection's address declaration.
307 ///
308 /// Unlike the relay case ([`Self::RelayAddressDeclarationRefused`]), this
309 /// is not a degraded-but-working connection. The relay keeps delivering
310 /// on established sessions in account-name space; a gateway has no such
311 /// space, so an unproved session can only be told verdicts and is never
312 /// registered as a recipient. The bridge therefore never reports the
313 /// carrier available, and this warning explains a transport that stays
314 /// down while its socket connects fine.
315 GatewayAddressDeclarationRefused,
316}
317
318impl SecurityWarningCode {
319 /// Returns the stable machine-readable reason code.
320 pub fn as_str(&self) -> &'static str {
321 match self {
322 Self::SenderAddressMismatch => "SENDER_ADDRESS_MISMATCH",
323 Self::TransportIdentityMismatch => "TRANSPORT_IDENTITY_MISMATCH",
324 Self::ControlSignatureInvalid => "CONTROL_SIGNATURE_INVALID",
325 Self::UnsignedControlRejected => "UNSIGNED_CONTROL_REJECTED",
326 Self::MediaSenderGroupMismatch => "MEDIA_SENDER_GROUP_MISMATCH",
327 Self::PlaintextSend => "PLAINTEXT_SEND",
328 Self::PlaintextReceiveRejected => "PLAINTEXT_RECEIVE_REJECTED",
329 Self::SessionSenderGroupMismatch => "SESSION_SENDER_GROUP_MISMATCH",
330 Self::SessionRekeyTriggered => "SESSION_REKEY_TRIGGERED",
331 Self::NostrKeyPackageSlotExhausted => "NOSTR_KEY_PACKAGE_SLOT_EXHAUSTED",
332 Self::PushKeyPackagePoolExhausted => "PUSH_KEY_PACKAGE_POOL_EXHAUSTED",
333 Self::RelayAddressBindingMismatch => "RELAY_ADDRESS_BINDING_MISMATCH",
334 Self::RelayAddressDeclarationRefused => "RELAY_ADDRESS_DECLARATION_REFUSED",
335 Self::GroupLeafIdentityUnproven => "GROUP_LEAF_IDENTITY_UNPROVEN",
336 Self::StaleControlFrame => "STALE_CONTROL_FRAME",
337 Self::GatewayAddressBindingMismatch => "GATEWAY_ADDRESS_BINDING_MISMATCH",
338 Self::GatewayAddressDeclarationRefused => "GATEWAY_ADDRESS_DECLARATION_REFUSED",
339 }
340 }
341}
342
343/// Machine-readable reason for DORS selection or switch (observability).
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
345#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
346pub enum DorsReasonCode {
347 /// First selection; no previous transport.
348 InitialSelection,
349 /// DORS selected primary (before send attempt).
350 PrimarySelected,
351 /// Primary send succeeded; active transport is now primary.
352 PrimarySuccess,
353 /// Primary failed; fallback send succeeded.
354 FallbackSuccess,
355 /// BLE → WiFi fallback succeeded (escalation applied).
356 EscalationApplied,
357 /// Previous transport unavailable; switched to best available.
358 CurrentUnavailable,
359}
360
361impl DorsReasonCode {
362 /// Returns the stable machine-readable reason code.
363 pub fn as_str(&self) -> &'static str {
364 match self {
365 Self::InitialSelection => "INITIAL_SELECTION",
366 Self::PrimarySelected => "PRIMARY_SELECTED",
367 Self::PrimarySuccess => "PRIMARY_SUCCESS",
368 Self::FallbackSuccess => "FALLBACK_SUCCESS",
369 Self::EscalationApplied => "ESCALATION_APPLIED",
370 Self::CurrentUnavailable => "CURRENT_UNAVAILABLE",
371 }
372 }
373}
374
375/// Phase of DORS escalation: recommendation (trigger boundary) vs actual transition (applied).
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
377#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
378pub enum DorsEscalationPhase {
379 /// DORS decided to escalate (typed trigger reason); fallback may not succeed.
380 Triggered,
381 /// BLE→WiFi fallback send succeeded; escalation was applied.
382 Applied,
383}
384
385impl DorsEscalationPhase {
386 /// Returns the stable string representation (TRIGGERED / APPLIED).
387 pub fn as_str(&self) -> &'static str {
388 match self {
389 Self::Triggered => "TRIGGERED",
390 Self::Applied => "APPLIED",
391 }
392 }
393}
394
395/// Machine-readable reason for DORS escalation (BLE→Wi‑Fi) observability.
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
397#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
398pub enum DorsEscalationReasonCode {
399 /// Fallback to WiFi succeeded after primary (BLE) send failed.
400 FallbackSuccess,
401 /// Escalation suggested due to retry threshold (e.g. ble_to_wifi_retry_threshold).
402 RetryThreshold,
403 /// Escalation suggested due to sustained poor BLE signal.
404 PoorSignal,
405 /// Escalation suggested due to congestion.
406 Congestion,
407 /// Escalation suggested due to low TTL on messages.
408 LowTtl,
409 /// Escalation suggested due to BLE success rate below configured minimum (quality degradation).
410 LowSuccessRate,
411}
412
413impl DorsEscalationReasonCode {
414 /// Returns the stable machine-readable reason code.
415 pub fn as_str(&self) -> &'static str {
416 match self {
417 Self::FallbackSuccess => "FALLBACK_SUCCESS",
418 Self::RetryThreshold => "RETRY_THRESHOLD",
419 Self::PoorSignal => "POOR_SIGNAL",
420 Self::Congestion => "CONGESTION",
421 Self::LowTtl => "LOW_TTL",
422 Self::LowSuccessRate => "LOW_SUCCESS_RATE",
423 }
424 }
425}
426
427/// Machine-readable reason taxonomy for inbound decryption failures.
428#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
429#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
430pub enum DecryptionFailureCode {
431 /// Incoming encrypted payload is malformed and cannot be parsed.
432 InvalidPayload,
433 /// MLS subsystem is not initialized locally.
434 NotInitialized,
435 /// Ciphertext is invalid or cannot be decrypted.
436 InvalidCiphertext,
437 /// Sender identity/signature verification failed.
438 IdentityMismatch,
439 /// Cryptographic operation failed.
440 CryptoFailure,
441 /// A media chunk was evicted from the pending-decryption queue (overflow or
442 /// TTL expiry) before the sender's session became ready, so the file
443 /// transfer it belongs to is currently stalled.
444 ///
445 /// Under the deferred-ACK model this is **advisory, not terminal**: the
446 /// evicted chunk was never ACKed, so the sender keeps retransmitting and a
447 /// later resend re-enters the queue and can still complete the transfer once
448 /// the session confirms. Treat this as "the transfer is stalled and may need
449 /// a resend", not "the transfer has permanently failed" — the terminal
450 /// failure signal for media is `FileReceiveFailed`. The same is true of a
451 /// hard decrypt failure while `crypto_recovery_enabled`, which surfaces
452 /// under its own code but is equally un-ACKed and equally recoverable by a
453 /// resend; see the `MessageDecryptionFailed` docs.
454 PendingQueueDropped,
455 /// Failure class is unknown.
456 Unknown,
457}
458
459/// Forwarding attribution in events (mirrors `ForwardInfo` from core).
460#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct ForwardInfoEvent {
462 /// The original sender of the message.
463 pub original_sender: String,
464 /// The original message ID.
465 pub original_message_id: String,
466 /// The original timestamp (wall-clock ms).
467 pub original_timestamp: i64,
468 /// Number of times this message has been forwarded.
469 pub forward_count: u32,
470}
471
472impl From<&offline_protocol_core::ForwardInfo> for ForwardInfoEvent {
473 fn from(fwd: &offline_protocol_core::ForwardInfo) -> Self {
474 Self {
475 original_sender: fwd.original_sender.as_str().to_string(),
476 original_message_id: fwd.original_message_id.as_str(),
477 original_timestamp: fwd.original_timestamp.as_millis(),
478 forward_count: fwd.forward_count,
479 }
480 }
481}
482
483/// Quoted-reply context in events (mirrors `ReplyContext` from core).
484#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct ReplyContextEvent {
486 /// Sender of the message being replied to.
487 pub sender: String,
488 /// Text (or excerpt) of the message being replied to.
489 pub text: String,
490 /// Timestamp of the quoted message (wall-clock ms).
491 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub timestamp: Option<i64>,
493 /// Short human-readable label for quoted media (e.g. a file name).
494 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub reply_media_label: Option<String>,
496 /// Content type of the quoted message, as a display string.
497 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub reply_content_type: Option<String>,
499}
500
501impl From<&offline_protocol_core::ReplyContext> for ReplyContextEvent {
502 fn from(rc: &offline_protocol_core::ReplyContext) -> Self {
503 Self {
504 sender: rc.sender.as_str().to_string(),
505 text: rc.text.clone(),
506 timestamp: rc.timestamp.map(|t| t.as_millis()),
507 reply_media_label: rc.reply_media_label.clone(),
508 reply_content_type: rc.reply_content_type.clone(),
509 }
510 }
511}
512
513/// One device's verified claim to a username.
514///
515/// Every field here has already passed verification: the address derives from
516/// the public key, the record's signature verifies under that key, and the
517/// record was published under the Nostr key it names. What that proves is
518/// narrow and worth stating exactly — **this key asserts this name**. It does
519/// not prove the name belongs to the claimant, because nothing can: the
520/// directory is non-authoritative by design.
521#[derive(Clone, Debug, Serialize, Deserialize)]
522pub struct UsernameClaim {
523 /// The claimed address, in canonical `off1…` form.
524 ///
525 /// This is the value to keep. An app that stores the *name* has stored
526 /// something anyone can re-claim tomorrow; the address is self-certifying.
527 pub address: String,
528 /// The Ed25519 identity key the address derives from, base64.
529 pub public_key: String,
530 /// When the claimant signed the record, in milliseconds since the epoch.
531 ///
532 /// **Advisory.** A record is not a liveness signal — the key-package fetch
533 /// that follows it is. An old claim from a peer who has been offline for a
534 /// month is still perfectly valid, and rejecting it here would make them
535 /// unreachable by name while their key packages sit live on a relay. Sort
536 /// by it if that helps a user choose; do not filter on it.
537 pub issued_at_ms: i64,
538}
539
540/// Events that can occur in the protocol.
541///
542/// Note: This type implements a custom Debug that redacts sensitive fields
543/// (message content) to prevent accidental logging of sensitive data.
544#[derive(Clone, Serialize, Deserialize)]
545#[serde(tag = "type", rename_all = "snake_case")]
546pub enum Event {
547 /// A message was sent (queued for delivery).
548 MessageSent {
549 /// ID of the sent message.
550 message_id: String,
551 /// Sender's user ID.
552 sender: String,
553 /// Recipient's user ID.
554 recipient: String,
555 /// Content of the sent message.
556 content: String,
557 /// Priority of the message when sent.
558 priority: String,
559 /// Whether the message requires an acknowledgement.
560 requires_ack: bool,
561 /// When the message was queued for delivery (wall-clock, for display).
562 timestamp: i64,
563 /// Lamport logical clock value for causal ordering.
564 #[serde(default)]
565 lamport_clock: u64,
566 /// Forwarding attribution (present when this is a forwarded message).
567 #[serde(default, skip_serializing_if = "Option::is_none")]
568 forward_info: Option<ForwardInfoEvent>,
569 },
570
571 /// A message was received.
572 MessageReceived {
573 /// ID of the received message.
574 message_id: String,
575 /// Sender's user ID.
576 sender: String,
577 /// Recipient's user ID.
578 recipient: String,
579 /// Message content.
580 content: String,
581 /// Number of hops the message traversed.
582 hop_count: u8,
583 /// Transport used for final delivery.
584 transport: String,
585 /// When the message was received (wall-clock, for display).
586 timestamp: i64,
587 /// Lamport logical clock value for causal ordering.
588 #[serde(default)]
589 lamport_clock: u64,
590 /// ID of the message this is replying to (optional).
591 #[serde(skip_serializing_if = "Option::is_none")]
592 reply_to_msg: Option<String>,
593 /// Quoted-reply context (present when this message quotes another).
594 /// Boxed to keep the `Event` enum's by-value size in check.
595 #[serde(default, skip_serializing_if = "Option::is_none")]
596 reply_context: Option<Box<ReplyContextEvent>>,
597 /// The type of content (text, image, video, etc.).
598 #[serde(default)]
599 content_type: String,
600 /// Media metadata (present for non-text content).
601 #[serde(default, skip_serializing_if = "Option::is_none")]
602 media_metadata: Option<MediaMetadata>,
603 /// Forwarding attribution (present when this is a forwarded message).
604 #[serde(default, skip_serializing_if = "Option::is_none")]
605 forward_info: Option<ForwardInfoEvent>,
606 /// `true` when the content arrived MLS-encrypted and was decrypted by
607 /// this node; `false` for plaintext accepted under the
608 /// `require_encryption = false` opt-out.
609 #[serde(default)]
610 encrypted: bool,
611 },
612
613 /// A message was successfully delivered (ACK received).
614 MessageDelivered {
615 /// ID of the delivered message.
616 message_id: String,
617 /// Latency in milliseconds.
618 latency_ms: u64,
619 /// Number of hops traversed.
620 hop_count: u8,
621 /// Transport used.
622 transport: String,
623 },
624
625 /// A message failed to deliver.
626 MessageFailed {
627 /// ID of the failed message.
628 message_id: String,
629 /// Reason for failure.
630 reason: String,
631 /// Number of retries attempted.
632 retry_count: u32,
633 },
634
635 /// An inbound encrypted message failed to decrypt on this attempt.
636 ///
637 /// **Advisory, not terminal, and emitted once per failed *attempt*.** While
638 /// `EncryptionConfig::crypto_recovery_enabled` is on (the default) a
639 /// message that fails to decrypt is not delivery-ACKed, so the sender keeps
640 /// retrying — and each resend of a DM is re-sealed against the peer's
641 /// current session, so a resend can actually land. Every failing attempt
642 /// reports again, bounded by the sender's ACK retry budget. Read this as
643 /// "this attempt did not decrypt"; the terminal signals are
644 /// [`Event::MessageFailed`] on the sender and `FileReceiveFailed` for
645 /// media.
646 MessageDecryptionFailed {
647 /// ID of the message that could not be decrypted.
648 message_id: String,
649 /// Sender's user ID.
650 sender: String,
651 /// Machine-readable decryption failure code.
652 code: DecryptionFailureCode,
653 /// Clear failure reason for application handling/logging.
654 reason: String,
655 },
656
657 /// Transport was switched by DORS.
658 TransportSwitched {
659 /// Previous transport (if any).
660 from: Option<String>,
661 /// New transport.
662 to: String,
663 /// Reason for switch.
664 reason: String,
665 },
666
667 /// This device started acting as a relay — carrying traffic for other
668 /// devices that cannot reach each other directly.
669 ///
670 /// Reports observed behaviour, not capability: it fires because this
671 /// device has been forwarding, so it needs no battery feed and can fire on
672 /// a device that has never reported a level.
673 RelayPromoted {
674 /// Neighbors held when this device started relaying.
675 connection_count: usize,
676 /// Battery level at the time, or `None` when the host has reported
677 /// none — the standing does not depend on it.
678 battery_level: Option<u8>,
679 },
680
681 /// This device was demoted from relay role.
682 RelayDemoted {
683 /// Reason for demotion.
684 reason: String,
685 },
686
687 /// A new neighbor was discovered.
688 NeighborDiscovered {
689 /// The peer's canonical user id — the same value the peer supplied
690 /// as `ProtocolConfig.user_id`, on every transport. Valid directly
691 /// as the `recipient` of `send_message` / `send_connection_request`.
692 peer_id: String,
693 /// Transport used to discover.
694 transport: String,
695 /// RSSI signal strength (if available).
696 rssi: Option<i16>,
697 },
698
699 /// A neighbor was lost (disconnected).
700 NeighborLost {
701 /// Peer ID of the lost neighbor.
702 peer_id: String,
703 },
704
705 /// This device's own address is known: MLS storage was opened and the
706 /// identity key in it derived to `address`.
707 ///
708 /// Fires once per successful `initialize_mls`, before any frame can be
709 /// sent. `address` is this device's `Message.sender` from here on, and what
710 /// peers must be given to reach it. It is stable across restarts of the
711 /// same profile, so an app that already stored it will be handed the same
712 /// value rather than a new one.
713 IdentityReady {
714 /// This device's self-certifying address (`off1…`).
715 address: String,
716 },
717
718 /// Network metrics update.
719 NetworkMetrics {
720 /// Number of active neighbors.
721 neighbor_count: usize,
722 /// Number of active relays.
723 relay_count: usize,
724 /// Message delivery ratio (0.0-1.0).
725 delivery_ratio: f32,
726 /// Average message latency in milliseconds.
727 avg_latency_ms: u64,
728 },
729
730 /// File transfer progress update.
731 FileProgress {
732 /// File identifier.
733 file_id: String,
734 /// Number of chunks sent so far.
735 chunks_sent: u32,
736 /// Total number of chunks.
737 total_chunks: u32,
738 /// Progress percentage (0-100).
739 percentage: u8,
740 },
741
742 /// File was completely received.
743 FileReceived {
744 /// File identifier.
745 file_id: String,
746 /// File name.
747 file_name: String,
748 /// File size in bytes.
749 file_size: u64,
750 /// Sender's user ID.
751 sender: String,
752 /// The content type of the media (image, video, file, etc.).
753 content_type: String,
754 /// Media metadata from the first chunk (present for typed media).
755 #[serde(default, skip_serializing_if = "Option::is_none")]
756 media_metadata: Option<MediaMetadata>,
757 /// Base64-encoded reassembled file data.
758 file_data: String,
759 /// When the sender queued the transfer (chunk-0 outer message
760 /// timestamp, wall-clock ms) — for display ordering alongside
761 /// `MessageReceived.timestamp`. Absent only if the chunk-0 record
762 /// was evicted before completion.
763 #[serde(default, skip_serializing_if = "Option::is_none")]
764 timestamp: Option<i64>,
765 /// Caption text from the sealed chunk-0 rich extras.
766 #[serde(default, skip_serializing_if = "Option::is_none")]
767 caption: Option<String>,
768 /// ID of the message this media replies to (sealed chunk-0 extras).
769 #[serde(default, skip_serializing_if = "Option::is_none")]
770 reply_to_msg: Option<String>,
771 /// Quoted-reply context (sealed chunk-0 extras). Boxed to keep the
772 /// `Event` enum's by-value size in check.
773 #[serde(default, skip_serializing_if = "Option::is_none")]
774 reply_context: Option<Box<ReplyContextEvent>>,
775 /// Forwarding attribution (sealed chunk-0 extras).
776 #[serde(default, skip_serializing_if = "Option::is_none")]
777 forward_info: Option<ForwardInfoEvent>,
778 },
779
780 /// An inbound file transfer was dropped before completion — the receiver
781 /// hit a resource limit (too many concurrent transfers, per-sender
782 /// quota, or the buffered-bytes budget), the reassembled file failed
783 /// its integrity checks, or the transfer went stale (no chunks within
784 /// the stale timeout). Terminal and fired at most once per transfer:
785 /// the failed transfer's remaining in-flight chunks are dropped
786 /// silently. No `FileReceived` will follow for this `file_id`; the
787 /// sender must re-send the file (under a fresh `file_id`) to retry.
788 FileReceiveFailed {
789 /// File identifier.
790 file_id: String,
791 /// File name from the chunk metadata.
792 file_name: String,
793 /// Sender's user ID.
794 sender: String,
795 /// Machine-readable reason the transfer was dropped.
796 reason: String,
797 },
798
799 /// All chunks of a media attachment were ACK-delivered.
800 MediaSent {
801 /// File identifier for tracking.
802 file_id: String,
803 /// The content type of the media.
804 content_type: String,
805 /// Recipient's user ID.
806 recipient: String,
807 },
808
809 /// An outbound media transfer was aborted before all chunks were
810 /// delivered — a chunk failed to encrypt, or a chunk failed terminally
811 /// in the outbox. No `MediaSent` will follow for this `file_id`; retry
812 /// with a new `send_media` call.
813 MediaSendFailed {
814 /// File identifier for tracking.
815 file_id: String,
816 /// Recipient's user ID.
817 recipient: String,
818 /// Reason the transfer was aborted.
819 reason: String,
820 },
821
822 /// A message was deferred due to network conditions.
823 /// The message will be retried automatically.
824 MessageDeferred {
825 /// ID of the deferred message.
826 message_id: String,
827 /// Recipient's user ID.
828 ///
829 /// Added when `reason` stopped rendering the transport error: the
830 /// counterparty used to reach apps only inside that prose, which put an
831 /// unhashed address on every telemetry record for a routine send
832 /// failure. Carried as a field so the scrubber hashes it like any
833 /// other actor id.
834 recipient: String,
835 /// Stable classification of the deferral, not a rendered error.
836 ///
837 /// One of a fixed local vocabulary (`peer_not_reachable`,
838 /// `transport_not_connected`, `transport_send_failed`, …). Branch on it
839 /// if you must, but do not parse it for detail: the underlying error —
840 /// which renders the peer — stays in the device log.
841 reason: String,
842 /// Current retry count.
843 retry_count: u32,
844 /// Next retry scheduled time (Unix timestamp ms).
845 next_retry_at: Option<i64>,
846 },
847
848 /// A retry for a previously deferred/unacked message was scheduled.
849 ///
850 /// Emitted each time the retry machinery re-queues a message after a
851 /// failed attempt (transport send error or ACK timeout). Non-terminal:
852 /// `MessageDelivered` or `MessageFailed` still settles the message.
853 MessageRetrying {
854 /// ID of the retrying message.
855 message_id: String,
856 /// Recipient's user ID.
857 recipient: String,
858 /// Retry count this schedule corresponds to.
859 retry_count: u32,
860 /// Absolute time the retry is scheduled for (Unix timestamp ms).
861 next_retry_at: i64,
862 },
863
864 /// The transport layer reported the recipient unreachable for an
865 /// in-flight message (e.g. the internet relay's DeliveryError).
866 ///
867 /// Non-terminal: the message stays in the outbox. A plain DM is
868 /// *parked* — its ACK retry budget stops burning while the peer is
869 /// provably offline — and is re-driven on reachability edges (transport
870 /// reconnect, peer discovery, presence-online; the SDK adds parked
871 /// recipients to the presence watchlist). It settles only via
872 /// `MessageDelivered` or, at outbox-lifetime expiry, `MessageFailed`.
873 /// Media chunks are not parked and keep the normal retry machinery.
874 /// May fire multiple times for the same `message_id` while the
875 /// recipient remains offline: a parked DM keeps an escalating
876 /// reachability probe on every carrier (15s → 600s cap), and each probe
877 /// that reaches the relay while the peer is still offline earns a fresh
878 /// verdict and re-emits this event. Apps must treat it as a repeatable
879 /// status signal, never as a terminal one. `file_id` is set when the
880 /// message is a chunk of an outbound media transfer.
881 MessageUndeliverable {
882 /// ID of the affected message.
883 message_id: String,
884 /// Recipient's user ID.
885 recipient: String,
886 /// Stable classification of the failure — `recipient_unreachable` on
887 /// the relay-verdict path this event is normally reached by.
888 ///
889 /// The transport's own wording used to be appended here verbatim, and
890 /// on the relay path that wording is written by the relay: unbounded
891 /// remote text, shipped beside a `recipient` the telemetry scrubber
892 /// hashes. It is now dropped at the boundary — kept in the device log,
893 /// and on the internet path still delivered raw to apps on the
894 /// bridges' `diagnostic` channel.
895 reason: String,
896 /// Owning media transfer when the message is a media chunk.
897 #[serde(default, skip_serializing_if = "Option::is_none")]
898 file_id: Option<String>,
899 },
900
901 /// An outbound media transfer was in flight when the previous process
902 /// died. The SDK persists only the transfer descriptor (never chunk
903 /// bytes), so it cannot resume the transfer itself: the app must
904 /// re-supply the file bytes via `send_media` with this `file_id` —
905 /// they are checksum-validated against the original transfer.
906 MediaResendRequired {
907 /// File identifier of the interrupted transfer.
908 file_id: String,
909 /// Recipient's user ID.
910 recipient: String,
911 /// Original file name.
912 file_name: String,
913 /// Total file size in bytes.
914 file_size: u64,
915 },
916
917 /// A pending ACK was evicted due to capacity constraints.
918 AckEvicted {
919 /// ID of the message whose ACK was evicted.
920 message_id: String,
921 /// Priority of the evicted message.
922 priority: String,
923 /// Reason for eviction.
924 reason: String,
925 },
926
927 /// A fragment assembly was evicted to make room for new fragments.
928 FragmentAssemblyEvicted {
929 /// ID of the message whose fragments were evicted.
930 message_id: String,
931 /// Completion percentage when evicted.
932 completion_percent: u8,
933 /// Reason for eviction.
934 reason: String,
935 },
936
937 /// Relay role was demoted due to battery constraints.
938 RelayDemotedBattery {
939 /// Battery level at time of demotion.
940 battery_level: u8,
941 /// Minimum required battery level.
942 min_required: u8,
943 },
944
945 /// A secure MLS session was successfully established.
946 SecureSessionEstablished {
947 /// Peer ID of the other party.
948 peer_id: String,
949 /// MLS group ID for the session.
950 group_id: String,
951 /// Whether this is a 1:1 session (true) or a multi-party group (false).
952 is_session: bool,
953 /// Whether the local device initiated the session (sent the Welcome).
954 initiated_by_local: bool,
955 },
956
957 /// Failed to establish a secure MLS session.
958 ///
959 /// Reports a failed *handshake attempt*, which is not the same as "the
960 /// session with this peer is gone". One case makes the difference visible:
961 /// a Welcome refused for carrying an unprovable identity
962 /// ([`SecurityWarningCode::GroupLeafIdentityUnproven`], emitted alongside)
963 /// is refused non-destructively, so any session already held with that peer
964 /// stays live and usable. Apps must not tear down session state on this
965 /// event alone.
966 ///
967 /// `reason` is diagnostic text and must not be parsed. It carries no
968 /// identifier, of this peer or of anyone else: the telemetry scrubber
969 /// hashes `peer_id` and ships `reason` verbatim, so every arm that fills it
970 /// contributes either a fixed string or
971 /// [`MlsError::privacy_safe_reason`](offline_protocol_mls::MlsError::privacy_safe_reason),
972 /// never a rendered error. The full error stays in the device log at the
973 /// refusal site. This is a classification, so apps get the failure *class*
974 /// here and must look to the log for the specifics.
975 SecureSessionFailed {
976 /// Peer ID of the other party.
977 peer_id: String,
978 /// Reason for the failure.
979 reason: String,
980 },
981
982 /// Diagnostic breadcrumb for the 1:1 MLS convergence (Welcome receive /
983 /// adopt / confirm) path. Emitted so the receiver side — which otherwise
984 /// produces no event until it fully establishes or loudly fails — is
985 /// observable in Metro. Pure instrumentation; carries no protocol effect.
986 ConvergenceDiag {
987 /// Fixed stage label, e.g. "welcome_received" or "welcome_branch".
988 stage: String,
989 /// Peer ID this breadcrumb concerns.
990 peer_id: String,
991 /// Free-form `key=value` context (counts, branch taken, errors).
992 detail: String,
993 },
994
995 /// Welcome send lifecycle entered attempted state.
996 WelcomeSendAttempted {
997 /// Peer identifier for this welcome lifecycle.
998 peer_id: String,
999 /// Welcome message identifier for lifecycle correlation.
1000 message_id: String,
1001 /// MLS group identifier associated with the welcome.
1002 group_id: String,
1003 /// 1-based send attempt number.
1004 attempt: u32,
1005 },
1006
1007 /// Welcome send lifecycle reached successful sent state.
1008 WelcomeSendSucceeded {
1009 /// Peer identifier for this welcome lifecycle.
1010 peer_id: String,
1011 /// Welcome message identifier for lifecycle correlation.
1012 message_id: String,
1013 /// MLS group identifier associated with the welcome.
1014 group_id: String,
1015 /// 1-based send attempt number.
1016 attempt: u32,
1017 },
1018
1019 /// Welcome send attempt failed and may be retried.
1020 WelcomeSendFailed {
1021 /// Peer identifier for this welcome lifecycle.
1022 peer_id: String,
1023 /// Welcome message identifier for lifecycle correlation.
1024 message_id: String,
1025 /// MLS group identifier associated with the welcome.
1026 group_id: String,
1027 /// 1-based send attempt number.
1028 attempt: u32,
1029 /// Machine-readable reason code.
1030 reason_code: WelcomeReasonCode,
1031 #[serde(skip_serializing_if = "Option::is_none")]
1032 /// Stable classification of the transport failure, when one was
1033 /// recorded — never the rendered error.
1034 ///
1035 /// Both feeders named an identifier: the platform-supplied string is
1036 /// the relay's own `DeliveryError` prose, and the locally raised one
1037 /// renders a transport error that interpolates the peer. `peer_id` and
1038 /// `group_id` beside it are hashed, so either would have undone that.
1039 /// Classified at the boundary, so the persisted record holds the token
1040 /// too — and again at each emit as a backstop. The second pass is
1041 /// unreachable today (every writer overwrites the field before its
1042 /// emit, so no record restored from an older build can carry raw text
1043 /// into an event) and kept because the field is persisted and has five
1044 /// writers: it holds the invariant if one of them ever becomes
1045 /// conditional.
1046 transport_error: Option<String>,
1047 /// Whether this failure will be retried.
1048 retryable: bool,
1049 #[serde(skip_serializing_if = "Option::is_none")]
1050 /// Next scheduled retry timestamp in Unix ms.
1051 next_retry_at: Option<i64>,
1052 },
1053
1054 /// Welcome send lifecycle reached terminal expiry.
1055 WelcomeSendExpired {
1056 /// Peer identifier for this welcome lifecycle.
1057 peer_id: String,
1058 /// Welcome message identifier for lifecycle correlation.
1059 message_id: String,
1060 /// Final attempt number when lifecycle expired.
1061 attempt: u32,
1062 /// Terminal machine-readable reason code.
1063 reason_code: WelcomeReasonCode,
1064 },
1065
1066 /// A connection request was received from another user.
1067 ConnectionRequestReceived {
1068 /// User ID of the sender.
1069 sender: String,
1070 /// Display name of the sender.
1071 sender_name: String,
1072 /// Timestamp of the request (Unix ms).
1073 timestamp: i64,
1074 /// MLS key package data (if provided for encrypted session setup).
1075 #[serde(skip_serializing_if = "Option::is_none")]
1076 key_package: Option<Vec<u8>>,
1077 /// Optional first message sent along with the request.
1078 #[serde(skip_serializing_if = "Option::is_none")]
1079 initial_message: Option<String>,
1080 },
1081
1082 /// An outbound connection request could not be delivered: the transport
1083 /// reported the recipient unreachable (e.g. the relay's DeliveryError
1084 /// for an offline peer), or the request exhausted its ACK retry budget
1085 /// (in which case a generic `MessageFailed` also fires for the same
1086 /// id). This is a status signal, not proof of permanent failure — the
1087 /// original request may still be delivered by the retry machinery if
1088 /// the peer comes back online. Apps typically surface "user is
1089 /// offline"; a user-initiated resend can therefore arrive alongside the
1090 /// retried original (the recipient sees the request event again, which
1091 /// accept/reject flows already tolerate).
1092 ConnectionRequestUndeliverable {
1093 /// Recipient the request was addressed to.
1094 recipient: String,
1095 /// Message id returned by `send_connection_request`.
1096 message_id: String,
1097 /// Failure reason, drawn from a fixed local vocabulary:
1098 /// `recipient_unreachable` on the transport verdict path, or
1099 /// `max_retries_exceeded` / `outbox_lifetime_exceeded` /
1100 /// `outbox_capacity_exceeded` on the give-up paths.
1101 ///
1102 /// The relay's wording is no longer appended to the first of these —
1103 /// it is remote-chosen text, and `recipient` beside it is hashed by
1104 /// the telemetry scrubber.
1105 reason: String,
1106 },
1107
1108 /// A previously sent connection request was accepted.
1109 ConnectionAccepted {
1110 /// User ID of the accepting party.
1111 accepted_by: String,
1112 /// Display name of the accepting party.
1113 accepted_by_name: String,
1114 /// Timestamp of the acceptance (Unix ms).
1115 timestamp: i64,
1116 /// MLS key package data (if provided for encrypted session setup).
1117 #[serde(skip_serializing_if = "Option::is_none")]
1118 key_package: Option<Vec<u8>>,
1119 },
1120
1121 /// A previously sent connection request was rejected.
1122 ConnectionRejected {
1123 /// User ID of the rejecting party.
1124 rejected_by: String,
1125 },
1126
1127 /// A previously sent connection request was cancelled by the sender.
1128 ConnectionRequestCancelled {
1129 /// User ID of the party who cancelled their request.
1130 cancelled_by: String,
1131 },
1132
1133 // --- Group (relay) events ---
1134 /// A group was created (from relay).
1135 GroupCreated {
1136 /// Group identifier.
1137 group_id: String,
1138 /// Human-readable group name.
1139 name: String,
1140 },
1141
1142 /// A message was received in a group (from relay).
1143 GroupMessageReceived {
1144 /// Group identifier.
1145 group_id: String,
1146 /// User ID of the sender.
1147 sender: String,
1148 /// Message content.
1149 content: String,
1150 /// ISO-8601 timestamp.
1151 timestamp: String,
1152 /// Unique message identifier.
1153 message_id: String,
1154 /// Optional reply-to message ID.
1155 #[serde(default, skip_serializing_if = "Option::is_none")]
1156 reply_to_msg: Option<String>,
1157 /// Forwarding attribution (present when this is a forwarded message).
1158 #[serde(default, skip_serializing_if = "Option::is_none")]
1159 forward_info: Option<ForwardInfoEvent>,
1160 /// Media metadata restored from the sealed `__RICH_V1__` body of a
1161 /// rich group message (cloud attachments — including the
1162 /// `encryption_key`/`iv` secrets, which only ever travel inside the
1163 /// group MLS ciphertext). Absent on plain group messages.
1164 #[serde(default, skip_serializing_if = "Option::is_none")]
1165 media_metadata: Option<MediaMetadata>,
1166 /// Content-type rendering hint from the sealed body (text, image,
1167 /// video, ...). Absent on plain group messages.
1168 #[serde(default, skip_serializing_if = "Option::is_none")]
1169 content_type: Option<String>,
1170 },
1171
1172 /// A member was added to a group (from relay).
1173 GroupMemberAdded {
1174 /// Group identifier.
1175 group_id: String,
1176 /// User ID of the added member.
1177 user_id: String,
1178 /// User ID of who performed the add.
1179 added_by: String,
1180 /// Human-readable group name (present when the invitee first joins).
1181 #[serde(skip_serializing_if = "Option::is_none")]
1182 group_name: Option<String>,
1183 /// Whether `added_by` was authorized to make this change.
1184 ///
1185 /// `Some(false)` means the change **did** happen — MLS accepted the
1186 /// commit and the roster really has changed — but the committer was
1187 /// not a known admin. The membership event is still emitted because
1188 /// the local roster must not diverge from MLS state; see
1189 /// [`Event::GroupUnauthorizedMembershipChange`] for the full signal.
1190 ///
1191 /// `None` (absent in JSON) means authorization was **not evaluated**
1192 /// on the path that produced this event: our own join accepted from
1193 /// a Welcome (there is no prior group state to judge the inviter
1194 /// against) and relay reconciliation frames (no authenticated
1195 /// committer to judge). Events from an older core also omit the
1196 /// field. Only `Some(_)` is a positive statement either way.
1197 ///
1198 /// The judgment is made against this member's **local replica** of
1199 /// role state, which replicates best-effort and can lag — a
1200 /// legitimate change may be flagged `Some(false)`, and different
1201 /// members can disagree about the same commit. Do not act on it
1202 /// automatically.
1203 #[serde(default, skip_serializing_if = "Option::is_none")]
1204 authorized: Option<bool>,
1205 },
1206
1207 /// A member was removed from a group (from relay).
1208 GroupMemberRemoved {
1209 /// Group identifier.
1210 group_id: String,
1211 /// User ID of the removed member.
1212 user_id: String,
1213 /// User ID of who performed the removal.
1214 removed_by: String,
1215 /// Whether this change was authorized (`None` = not evaluated on
1216 /// this path). See [`Event::GroupMemberAdded::authorized`]. On the
1217 /// relay reconciliation path the judgment applies to the frame's
1218 /// authenticated wire sender, which is not necessarily the
1219 /// `removed_by` named here.
1220 #[serde(default, skip_serializing_if = "Option::is_none")]
1221 authorized: Option<bool>,
1222 },
1223
1224 /// Group info was received (from relay).
1225 GroupInfo {
1226 /// Group identifier.
1227 group_id: String,
1228 /// Human-readable group name.
1229 name: String,
1230 /// User ID of the group creator.
1231 created_by: String,
1232 /// ISO-8601 creation timestamp.
1233 created_at: String,
1234 /// Group member list.
1235 members: Vec<GroupInfoMember>,
1236 },
1237
1238 /// User's groups list was received (from relay).
1239 UserGroups {
1240 /// List of group summaries.
1241 groups: Vec<UserGroupSummary>,
1242 },
1243
1244 /// A group operation failed (from relay).
1245 ///
1246 /// The relay's own wording is **not** carried here. `__GROUP_ERROR__` is
1247 /// a relay answer, accepted unsigned on the relay ingest path (the
1248 /// bridges inject it unattributed), so its text is chosen by whoever put
1249 /// the frame on that socket — arbitrary content, arbitrary length, and
1250 /// not something this SDK can vouch for. `reason` is a fixed code
1251 /// classified locally instead; the
1252 /// relay's exact text is logged on-device and reaches apps, when they
1253 /// want it, as the raw `GroupError` frame the bridges dual-emit on the
1254 /// server-message channel.
1255 GroupError {
1256 /// Why the operation failed, from a fixed set: `not_found` (the
1257 /// relay has no such group — invite links and relay fan-out for it
1258 /// are dead, not merely refused), `sync_denied` (the relay refused
1259 /// to register or sync the group for this caller), or `error` (any
1260 /// other refusal).
1261 reason: String,
1262 /// Group the error concerns, when the relay scoped it. Absent for
1263 /// unscoped errors.
1264 ///
1265 /// This is the group-scoping the free-text `reason` used to smuggle
1266 /// past the telemetry scrubber — as a real field it is hashed like
1267 /// every other `group_id` instead.
1268 #[serde(default, skip_serializing_if = "Option::is_none")]
1269 group_id: Option<String>,
1270 },
1271
1272 /// The relay-side registration state of a group changed.
1273 ///
1274 /// `synced: true` fires only on the relay's positive registration
1275 /// acknowledgment (a `GroupCreated` answering a registration this
1276 /// device actually sent, over the Internet transport) — including the
1277 /// idempotent re-sync ack after a membership change. It is the signal
1278 /// that relay-dependent server commands for the group (invite links,
1279 /// server-side fan-out) can be issued. `synced: false` fires when that
1280 /// trust is revoked; `reason` says why. Not emitted for groups the
1281 /// relay was never asked about.
1282 GroupRelaySyncChanged {
1283 /// MLS group identifier.
1284 group_id: String,
1285 /// Whether the relay currently holds a positively acknowledged
1286 /// registration for this group.
1287 synced: bool,
1288 /// Why the state changed: `registered` (relay ack), `error`
1289 /// (group-scoped relay error), `removed` (we were removed from the
1290 /// group), `left` (local leave), `internet_dropped` (Internet
1291 /// transport lost; re-registration re-arms on reconnect), or
1292 /// `ack_timeout` (relay never answered the registration — likely a
1293 /// relay without group support).
1294 reason: String,
1295 },
1296
1297 /// A username resolution finished, carrying **every** claim found.
1298 ///
1299 /// # The set is the whole point, and there is deliberately no "best" claim
1300 ///
1301 /// Anyone may publish any username claim, so a name resolves to a set of
1302 /// assertions, never to an answer. Even a single-device user is a set of
1303 /// one. This event is emitted exactly once per resolution and carries the
1304 /// complete set precisely so an app cannot accidentally treat the first
1305 /// arrival as the winner: there is no per-claim event to race, and no
1306 /// ordering field to mistake for a ranking.
1307 ///
1308 /// **An app MUST let the user choose.** Silently picking one claim converts
1309 /// a non-authoritative directory into an authoritative-looking one, which
1310 /// is worse than not shipping the feature: the user believes they are
1311 /// talking to the name, and the protocol only ever promised them a key.
1312 /// Present the claims, let a human confirm out of band (a QR code, a
1313 /// shared secret, a voice), and remember the address rather than the name.
1314 ///
1315 /// An empty `claims` list is an ordinary outcome: the name may be
1316 /// unclaimed, the claimant offline-published to relays we do not use, or
1317 /// every claim we saw failed verification.
1318 UsernameResolved {
1319 /// The normalized username that was resolved.
1320 username: String,
1321 /// Every claim that verified, in no meaningful order.
1322 claims: Vec<UsernameClaim>,
1323 /// How many records were seen but rejected.
1324 ///
1325 /// Non-zero is *normal*, not an error: the tag is public, anyone may
1326 /// publish to it, and junk arrives. Surfaced because a resolution that
1327 /// returns nothing while rejecting many records means something
1328 /// different from one that returns nothing having seen nothing, and an
1329 /// app showing "not found" may want to distinguish them.
1330 rejected: u32,
1331 /// How many *verified* claims were dropped at the accumulator's
1332 /// ceiling.
1333 ///
1334 /// Separate from `rejected` because it is the opposite statement: these
1335 /// records were good and are missing anyway. Without it a crowded name
1336 /// renders exactly like an uncrowded one, a clean set with nothing
1337 /// refused, which hides the single signal that a name is being
1338 /// squatted at volume. Non-zero means `claims` is a sample and no
1339 /// absence in it can be trusted.
1340 truncated: u32,
1341 },
1342
1343 /// A group message was sent to all members via mesh (MLS-encrypted fan-out).
1344 GroupMessageSent {
1345 /// MLS group identifier.
1346 group_id: String,
1347 /// Per-member message IDs from fan-out.
1348 message_ids: Vec<String>,
1349 /// Number of members the message was sent to.
1350 member_count: u32,
1351 },
1352
1353 /// A group message was only partially delivered (some members failed).
1354 GroupMessagePartialFailure {
1355 /// MLS group identifier.
1356 group_id: String,
1357 /// Members for whom send failed.
1358 failed_members: Vec<String>,
1359 /// Members for whom send succeeded.
1360 succeeded_members: Vec<String>,
1361 },
1362
1363 /// The relay's settled per-recipient delivery report for a group
1364 /// message sent via relay broadcast, after the SDK acted on it.
1365 ///
1366 /// Fires once per broadcast whose report arrived, seconds after
1367 /// `group_message_sent` (the relay reports only when its whole fan-out
1368 /// has settled — correlate by `message_id`, never by order). Members in
1369 /// `delivered` took the message over a live relay socket; members in
1370 /// `pushed` took a device push carrying the ciphertext. Every other
1371 /// MLS roster member — the ones the relay reported missed *and* the
1372 /// ones it did not know about at all — has already been re-sent a
1373 /// per-member copy through the ordinary outbox/ACK/park delivery
1374 /// ladder (`missed_reissued`), so no app action is required; the event
1375 /// is delivery observability, not a failure signal. Not emitted when
1376 /// the report itself is lost — the SDK then re-broadcasts (bounded)
1377 /// and finally downgrades the whole message to per-member fan-out,
1378 /// which reports per-member like any mesh send.
1379 GroupMessageDeliveryReport {
1380 /// MLS group identifier.
1381 group_id: String,
1382 /// The logical group message id, as returned by the send and echoed
1383 /// by the relay.
1384 message_id: String,
1385 /// Members whose relay socket write was confirmed (a relay-side
1386 /// write-ack, not an end-to-end delivery ACK).
1387 delivered: Vec<String>,
1388 /// Members offline at the relay who took a device push.
1389 pushed: Vec<String>,
1390 /// Members re-sent a per-member copy because the relay reached them
1391 /// neither way (or never knew them).
1392 missed_reissued: Vec<String>,
1393 },
1394
1395 /// Rich media metadata was dropped from an outbound group message
1396 /// because the group is not fully rich-capable (not every member
1397 /// advertised sealed rich payload support, or the local kill switch
1398 /// disabled it). The text was still sent; members receive it without
1399 /// the media attachment. Apps can use this to warn the sender that an
1400 /// attachment did not go through.
1401 GroupRichExtrasDropped {
1402 /// MLS group identifier.
1403 group_id: String,
1404 /// Members not known to parse the sealed rich payload — the ones
1405 /// holding the seal gate closed. Absence of knowledge and known
1406 /// non-support are indistinguishable here. Empty when the drop was
1407 /// caused by the local `rich_payload_enabled` kill switch instead.
1408 /// The SDK probes these members for their capability automatically;
1409 /// apps can retry the send once a later attempt stops dropping.
1410 unknown_members: Vec<String>,
1411 },
1412
1413 /// An MLS membership change was applied that its committer was not
1414 /// authorized to make.
1415 ///
1416 /// The change **has been applied** — MLS authenticated the committer as a
1417 /// group member and accepted the commit, and the local epoch advanced with
1418 /// it. The SDK's admin model is an application-layer overlay on top of MLS
1419 /// (RFC 9420 leaves membership access control to the application), enforced
1420 /// on the *sending* side; refusing the change on receipt would mean
1421 /// refusing the merge, which permanently forks this member away from every
1422 /// peer that accepted it. So the change stands and is reported here instead.
1423 ///
1424 /// **This signal can false-positive.** "Unauthorized" is judged against
1425 /// this member's local replica of role state, which replicates
1426 /// best-effort (a joiner receives a point-in-time snapshot, role changes
1427 /// ride unreconciled mesh notifications) and can lag — so a legitimate
1428 /// change, e.g. a voluntary leave committed by a member whose admin role
1429 /// this device has not yet learned, may be reported here, and different
1430 /// members can disagree about the same commit.
1431 ///
1432 /// Apps should treat this as a moderation signal for a *human* admin: an
1433 /// admin can undo the change with `remove_from_group` /
1434 /// `invite_to_group`. Never reverse a change automatically off a single
1435 /// member's event — corroborate first. A member added this way can read
1436 /// all subsequent group traffic until removed.
1437 ///
1438 /// Reports are rate-limited per `(group, committer)`: a repeat within a
1439 /// short window is not re-emitted (divergent role metadata would
1440 /// otherwise re-fire it on every commit), but every affected
1441 /// [`Event::GroupMemberAdded`] / [`Event::GroupMemberRemoved`] still
1442 /// carries `authorized: Some(false)`.
1443 ///
1444 /// Known limitation: the member *removed* by an unauthorized Remove does
1445 /// not receive this event — it can no longer decrypt the commit that
1446 /// removed it, and it refuses to trust a non-admin's unencrypted claim
1447 /// that it was removed. Only the remaining members report the removal.
1448 GroupUnauthorizedMembershipChange {
1449 /// MLS group identifier.
1450 group_id: String,
1451 /// The MLS-authenticated committer that made the change.
1452 committer: String,
1453 /// Members the commit added, sorted. Empty for a pure removal.
1454 added: Vec<String>,
1455 /// Members the commit removed, sorted. Empty for a pure addition.
1456 removed: Vec<String>,
1457 /// Why the change was judged unauthorized. Currently
1458 /// `"sender_not_admin"` (the committer is not a known admin) or
1459 /// `"affected_member_mismatch"` (an admin committed, but the commit's
1460 /// unencrypted framing named a different member than the MLS delta
1461 /// actually changed). Treat as an opaque string — values may be added.
1462 reason: String,
1463 /// Whether the commit was *refused* rather than applied.
1464 ///
1465 /// `false` (the default configuration) means the membership change
1466 /// happened and is being reported after the fact — `added`/`removed`
1467 /// describe real roster changes an admin can undo. `true` means
1468 /// `GroupConfig::enforce_admin_commits` was on and the commit was
1469 /// rejected before merging: no roster event accompanies this one, no
1470 /// membership changed locally, and `added`/`removed` describe what the
1471 /// commit *would* have done.
1472 ///
1473 /// A `true` here also means this device declined an epoch every
1474 /// accepting member advanced to, so it can no longer decrypt that
1475 /// group's traffic and needs re-inviting — treat it as a partition
1476 /// alarm, not just a moderation signal. Note the re-invite arrives as
1477 /// a Welcome, which is not policy-gated, so it readmits us to the
1478 /// group *including* whatever the refused commit did; the change
1479 /// itself still has to be resolved separately.
1480 enforced: bool,
1481 },
1482
1483 /// An epoch fork was detected in a group — concurrent commits caused
1484 /// members to diverge onto different MLS branches. The deterministic
1485 /// leader will attempt automatic resolution via a key-update commit.
1486 GroupEpochForkDetected {
1487 /// MLS group identifier.
1488 group_id: String,
1489 /// Our local epoch when the fork was detected, or `None` if MLS
1490 /// state was unavailable at detection time.
1491 local_epoch: Option<u64>,
1492 },
1493
1494 /// An epoch fork was successfully resolved by the leader issuing a
1495 /// resync commit that re-established a canonical epoch.
1496 ///
1497 /// Members in `failed_members` could not be reached with the resolution
1498 /// commit and may still be on a forked branch — the application layer
1499 /// should consider re-inviting them.
1500 GroupEpochForkResolved {
1501 /// MLS group identifier.
1502 group_id: String,
1503 /// The new canonical epoch after resolution.
1504 resolved_epoch: u64,
1505 /// Members to whom the resolution commit could not be sent.
1506 /// These members may still be on a forked epoch branch and
1507 /// may need a re-invite to rejoin the canonical group state.
1508 failed_members: Vec<String>,
1509 },
1510
1511 /// A member's role was changed in a group.
1512 GroupRoleChanged {
1513 /// Group identifier.
1514 group_id: String,
1515 /// User ID of the member whose role changed.
1516 user_id: String,
1517 /// New role (e.g. "admin", "member").
1518 new_role: String,
1519 /// User ID of who changed the role.
1520 changed_by: String,
1521 },
1522
1523 /// A group was renamed.
1524 GroupRenamed {
1525 /// Group identifier.
1526 group_id: String,
1527 /// New group name.
1528 new_name: String,
1529 /// Previous group name (if known).
1530 old_name: Option<String>,
1531 /// User ID of who renamed the group.
1532 renamed_by: String,
1533 },
1534
1535 // --- Service discovery ---
1536 /// A service was discovered on the mesh in response to a discovery query.
1537 ServiceDiscovered {
1538 /// Query ID that triggered this discovery.
1539 query_id: String,
1540 /// Service identifier.
1541 service_id: String,
1542 /// Service version.
1543 version: String,
1544 /// Peer user ID of the provider.
1545 provider_peer_id: String,
1546 /// Service capabilities.
1547 capabilities: HashMap<String, String>,
1548 /// Number of hops from the provider.
1549 hop_count: u8,
1550 },
1551
1552 /// A service request was received from another peer.
1553 ServiceRequestReceived {
1554 /// Unique request identifier.
1555 request_id: String,
1556 /// Service identifier being invoked.
1557 service_id: String,
1558 /// Method name or action.
1559 method: String,
1560 /// Request body (JSON or arbitrary string).
1561 body: String,
1562 /// Peer user ID of the requester.
1563 sender: String,
1564 },
1565
1566 /// A response to a service request was received.
1567 ServiceResponseReceived {
1568 /// Request identifier this response corresponds to.
1569 request_id: String,
1570 /// Service identifier.
1571 service_id: String,
1572 /// Status: "ok", "not_found", or "error".
1573 status: String,
1574 /// Response body.
1575 body: String,
1576 /// Peer user ID of the provider.
1577 provider_peer_id: String,
1578 },
1579
1580 // --- Presence, typing, and read receipts ---
1581 /// A peer's presence status was updated.
1582 PresenceUpdated {
1583 /// Peer whose presence changed.
1584 peer_id: String,
1585 /// Presence status.
1586 status: PresenceStatus,
1587 /// Timestamp of the update (Unix ms).
1588 timestamp: i64,
1589 /// When the peer was last seen (Unix ms), if the source knows it —
1590 /// e.g. the internet relay's PresenceStatusWithLastSeen. Absent for
1591 /// peer-sent `__PRESENCE__` updates.
1592 #[serde(default, skip_serializing_if = "Option::is_none")]
1593 last_seen_ms: Option<i64>,
1594 /// Which channel produced this update: the internet relay's
1595 /// authoritative answer, or a peer-sent self-report. Always
1596 /// serialized; defaults to `peer` when deserializing legacy events.
1597 #[serde(default)]
1598 source: PresenceSource,
1599 },
1600
1601 /// A typing indicator was received from a peer.
1602 TypingIndicatorReceived {
1603 /// User who is typing (or stopped typing).
1604 sender: String,
1605 /// Conversation identifier, echoed back verbatim from the sender.
1606 ///
1607 /// Opaque to the SDK — never parsed or routed on. Whatever key the
1608 /// sender chose (conventionally a peer address for a DM, the group
1609 /// id for a group) arrives here unchanged.
1610 conversation_id: String,
1611 /// Whether the user is currently typing.
1612 is_typing: bool,
1613 /// Timestamp of the indicator (Unix ms).
1614 timestamp: i64,
1615 },
1616
1617 /// A read receipt was received from a peer.
1618 ReadReceiptReceived {
1619 /// User who read the messages.
1620 sender: String,
1621 /// IDs of the messages that were read.
1622 message_ids: Vec<String>,
1623 /// Timestamp when the messages were read (Unix ms).
1624 timestamp: i64,
1625 },
1626
1627 // --- DORS observability (OFF-258) ---
1628 /// DORS scored all available transports for this decision cycle.
1629 DorsScoreUpdated {
1630 /// Transport type and score pairs (descending by score).
1631 scores: Vec<(String, f32)>,
1632 },
1633 /// DORS selected a transport for the current message.
1634 DorsTransportSelected {
1635 /// Previous transport (if any); provides decision boundary context.
1636 from: Option<String>,
1637 /// Selected transport.
1638 transport: String,
1639 /// Typed reason for selection (initial vs primary selected).
1640 reason_code: DorsReasonCode,
1641 /// Score of the selected transport (supplemental).
1642 score: f32,
1643 },
1644 /// DORS switched from one transport to another. Emitted only when active transport
1645 /// actually changes after a successful send.
1646 DorsTransportSwitched {
1647 /// Previous transport (if any).
1648 from: Option<String>,
1649 /// New transport (now active).
1650 to: String,
1651 /// Stable enum-backed reason for the transition.
1652 reason_code: DorsReasonCode,
1653 /// Optional human-readable context (e.g. "primary failed, fallback succeeded").
1654 #[serde(skip_serializing_if = "Option::is_none")]
1655 reason_detail: Option<String>,
1656 },
1657 /// DORS escalation from BLE to Wi‑Fi. Use `phase` to distinguish recommendation vs applied.
1658 DorsEscalationTriggered {
1659 /// TRIGGERED = DORS decided to escalate (reason = trigger); APPLIED = fallback succeeded.
1660 phase: DorsEscalationPhase,
1661 /// Transport we escalated from (e.g. "ble").
1662 from: String,
1663 /// Transport we escalated to (e.g. "wifiDirect").
1664 to: String,
1665 /// Stable enum-backed reason code.
1666 reason_code: DorsEscalationReasonCode,
1667 /// Optional human-readable context.
1668 #[serde(skip_serializing_if = "Option::is_none")]
1669 reason_detail: Option<String>,
1670 },
1671
1672 /// A security-relevant anomaly was detected (e.g. sender spoofing, a
1673 /// signing key that does not derive to its claimed address, an unsigned
1674 /// control message).
1675 SecurityWarning {
1676 /// Peer ID involved in the warning.
1677 peer_id: String,
1678 /// Stable machine-readable classification of the warning. Branch on
1679 /// this rather than parsing `reason`.
1680 reason_code: SecurityWarningCode,
1681 /// Human-readable description of the security issue.
1682 reason: String,
1683 },
1684
1685 /// A message was relayed (forwarded for a third party).
1686 MessageRelayed {
1687 /// ID of the relayed message.
1688 message_id: String,
1689 /// Original sender.
1690 sender: String,
1691 /// Intended recipient.
1692 recipient: String,
1693 /// Current hop count after increment.
1694 hop_count: u8,
1695 /// Remaining TTL after decrement.
1696 remaining_ttl: u8,
1697 },
1698
1699 /// A user was blocked. Emitted for local UI notification only.
1700 UserBlocked {
1701 /// User ID that was blocked.
1702 user_id: String,
1703 },
1704
1705 /// A user was unblocked. Emitted for local UI notification only.
1706 UserUnblocked {
1707 /// User ID that was unblocked.
1708 user_id: String,
1709 },
1710
1711 /// A replicated document changed and the change reached storage.
1712 ///
1713 /// Emitted after the delta is durable, never before: an application that
1714 /// re-renders on this event is then rendering state that survives a
1715 /// crash.
1716 DataChanged {
1717 /// The space the document belongs to.
1718 space_id: String,
1719 /// The document that changed.
1720 doc_id: String,
1721 /// Size of the change that was persisted.
1722 delta_bytes: u64,
1723 },
1724
1725 /// A replicated document is approaching the per-document size cap.
1726 ///
1727 /// Fires while there is still room to act. Without it the cap would be a
1728 /// cliff an application meets for the first time as a failed write.
1729 DataDocSizeWarning {
1730 /// The space the document belongs to.
1731 space_id: String,
1732 /// The document approaching the cap.
1733 doc_id: String,
1734 /// Its current compacted size.
1735 compacted_bytes: u64,
1736 /// The cap it is measured against.
1737 cap_bytes: u64,
1738 },
1739
1740 /// A peer asked for the bytes behind an attachment reference.
1741 ///
1742 /// The SDK does not hold blob bytes, so it cannot answer this on its
1743 /// own: the application either supplies them with
1744 /// `data_provide_attachment` or says it cannot with
1745 /// `data_decline_attachment`. An application that handles neither leaves
1746 /// the asking peer with a reference it can see and never open, which is
1747 /// why declining is a first-class answer rather than silence.
1748 DataAttachmentRequested {
1749 /// The space the request arrived in.
1750 space_id: String,
1751 /// The peer that asked.
1752 peer_id: String,
1753 /// Lowercase hex SHA-256 of the blob they want.
1754 hash: String,
1755 },
1756
1757 /// Attachment bytes arrived and matched the hash that asked for them.
1758 ///
1759 /// The bytes are handed over rather than stored: blobs never enter
1760 /// protocol state, so where this file lives is the application's
1761 /// decision and its storage.
1762 DataAttachmentReceived {
1763 /// The space the attachment arrived in.
1764 space_id: String,
1765 /// The peer that supplied it.
1766 peer_id: String,
1767 /// Lowercase hex SHA-256 of the blob, verified against these bytes.
1768 hash: String,
1769 /// The blob, base64.
1770 data: String,
1771 },
1772
1773 /// A fetch ended without bytes.
1774 ///
1775 /// Either the peer answered that it no longer holds the blob, or the
1776 /// transfer carrying it failed. Both are ordinary: an attachment
1777 /// reference outlives the bytes it names, because the reference
1778 /// replicates and the bytes do not.
1779 DataAttachmentUnavailable {
1780 /// The space the fetch was made in.
1781 space_id: String,
1782 /// The peer that was asked.
1783 peer_id: String,
1784 /// Lowercase hex SHA-256 that was asked for.
1785 hash: String,
1786 /// Why it ended: `declined` (the peer answered that it no longer
1787 /// holds the blob), `timeout`, `evicted` (displaced by newer
1788 /// fetches), `hash_mismatch`, `peer_gone` (the peer was blocked,
1789 /// forgotten, or came back without the replication capability), or a
1790 /// transfer
1791 /// failure reason. A fixed token this crate chooses, never a peer's
1792 /// words.
1793 reason: String,
1794 },
1795
1796 /// A document cannot be replicated, in any form this protocol has.
1797 ///
1798 /// The honest report of a real dead end rather than a warning in a log
1799 /// nobody reads. It fires when a document is too large for a sync frame
1800 /// and the peer cannot carry it over the media path either, which leaves
1801 /// two replicas that will not converge and no further rung to try.
1802 DataDocUnsyncable {
1803 /// The space the document belongs to.
1804 space_id: String,
1805 /// The document that cannot be sent.
1806 doc_id: String,
1807 /// Size of the encoding that had nowhere to go.
1808 bytes: u64,
1809 /// Why it could not be sent.
1810 reason: String,
1811 },
1812}
1813
1814/// Member entry in GroupInfo.
1815#[derive(Debug, Clone, Serialize, Deserialize)]
1816pub struct GroupInfoMember {
1817 /// User ID of the member.
1818 pub user_id: String,
1819 /// Role within the group (e.g. "admin", "member").
1820 pub role: String,
1821 /// ISO-8601 timestamp when the member joined.
1822 pub joined_at: String,
1823}
1824
1825/// Group summary in UserGroups.
1826#[derive(Debug, Clone, Serialize, Deserialize)]
1827pub struct UserGroupSummary {
1828 /// Group identifier.
1829 pub group_id: String,
1830 /// Human-readable group name.
1831 pub name: String,
1832 /// ISO-8601 creation timestamp.
1833 pub created_at: String,
1834}
1835
1836impl Event {
1837 /// Creates a MessageSent event.
1838 pub fn message_sent(message: &Message) -> Self {
1839 use offline_protocol_core::MessagePriority;
1840
1841 let priority = match message.priority {
1842 MessagePriority::Low => "low",
1843 MessagePriority::Medium => "medium",
1844 MessagePriority::High => "high",
1845 MessagePriority::Critical => "critical",
1846 };
1847
1848 let forward_info = message.forwarded_from.as_ref().map(ForwardInfoEvent::from);
1849
1850 Self::MessageSent {
1851 message_id: message.id.as_str(),
1852 sender: message.sender.as_str().to_string(),
1853 recipient: message.recipient.as_str().to_string(),
1854 content: message.content.clone(),
1855 priority: priority.to_string(),
1856 requires_ack: message.requires_ack,
1857 timestamp: message.timestamp.as_millis(),
1858 lamport_clock: message.lamport_clock.value(),
1859 forward_info,
1860 }
1861 }
1862
1863 /// Creates a MessageDelivered event.
1864 pub fn message_delivered(
1865 message_id: MessageId,
1866 latency_ms: u64,
1867 hop_count: u8,
1868 transport: TransportType,
1869 ) -> Self {
1870 Self::MessageDelivered {
1871 message_id: message_id.as_str(),
1872 latency_ms,
1873 hop_count,
1874 transport: transport.to_string(),
1875 }
1876 }
1877
1878 /// Creates a MessageFailed event.
1879 pub fn message_failed(message_id: MessageId, reason: String, retry_count: u32) -> Self {
1880 Self::MessageFailed {
1881 message_id: message_id.as_str(),
1882 reason,
1883 retry_count,
1884 }
1885 }
1886
1887 /// Creates a MessageDecryptionFailed event.
1888 pub fn message_decryption_failed(
1889 message_id: MessageId,
1890 sender: String,
1891 code: DecryptionFailureCode,
1892 reason: String,
1893 ) -> Self {
1894 Self::MessageDecryptionFailed {
1895 message_id: message_id.as_str(),
1896 sender,
1897 code,
1898 reason,
1899 }
1900 }
1901
1902 /// Creates a TransportSwitched event.
1903 pub fn transport_switched(
1904 from: Option<TransportType>,
1905 to: TransportType,
1906 reason: String,
1907 ) -> Self {
1908 Self::TransportSwitched {
1909 from: from.map(|t| t.to_string()),
1910 to: to.to_string(),
1911 reason,
1912 }
1913 }
1914
1915 /// Creates a RelayPromoted event.
1916 pub fn relay_promoted(connection_count: usize, battery_level: Option<u8>) -> Self {
1917 Self::RelayPromoted {
1918 connection_count,
1919 battery_level,
1920 }
1921 }
1922
1923 /// Creates a RelayDemoted event.
1924 pub fn relay_demoted(reason: String) -> Self {
1925 Self::RelayDemoted { reason }
1926 }
1927
1928 /// Creates a NeighborDiscovered event.
1929 pub fn neighbor_discovered(
1930 peer_id: String,
1931 transport: TransportType,
1932 rssi: Option<i16>,
1933 ) -> Self {
1934 Self::NeighborDiscovered {
1935 peer_id,
1936 transport: format!("{:?}", transport),
1937 rssi,
1938 }
1939 }
1940
1941 /// Creates a NeighborLost event.
1942 pub fn neighbor_lost(peer_id: String) -> Self {
1943 Self::NeighborLost { peer_id }
1944 }
1945
1946 /// Creates an IdentityReady event.
1947 pub fn identity_ready(address: impl Into<String>) -> Self {
1948 Self::IdentityReady {
1949 address: address.into(),
1950 }
1951 }
1952
1953 /// Creates a NetworkMetrics event.
1954 pub fn network_metrics(
1955 neighbor_count: usize,
1956 relay_count: usize,
1957 delivery_ratio: f32,
1958 avg_latency_ms: u64,
1959 ) -> Self {
1960 Self::NetworkMetrics {
1961 neighbor_count,
1962 relay_count,
1963 delivery_ratio,
1964 avg_latency_ms,
1965 }
1966 }
1967
1968 /// Creates a FileProgress event.
1969 pub fn file_progress(file_id: String, chunks_sent: u32, total_chunks: u32) -> Self {
1970 let percentage = if total_chunks > 0 {
1971 ((chunks_sent as f32 / total_chunks as f32) * 100.0) as u8
1972 } else {
1973 0
1974 };
1975
1976 Self::FileProgress {
1977 file_id,
1978 chunks_sent,
1979 total_chunks,
1980 percentage,
1981 }
1982 }
1983
1984 /// Creates a FileReceived event.
1985 #[allow(clippy::too_many_arguments)]
1986 pub fn file_received(
1987 file_id: String,
1988 file_name: String,
1989 file_size: u64,
1990 sender: String,
1991 content_type: ContentType,
1992 media_metadata: Option<MediaMetadata>,
1993 file_data: Vec<u8>,
1994 timestamp: Option<i64>,
1995 caption: Option<String>,
1996 reply_to_msg: Option<String>,
1997 reply_context: Option<&offline_protocol_core::ReplyContext>,
1998 forward_info: Option<&offline_protocol_core::ForwardInfo>,
1999 ) -> Self {
2000 use base64::{engine::general_purpose::STANDARD, Engine};
2001 Self::FileReceived {
2002 file_id,
2003 file_name,
2004 file_size,
2005 sender,
2006 content_type: content_type.to_string(),
2007 media_metadata,
2008 file_data: STANDARD.encode(&file_data),
2009 timestamp,
2010 caption,
2011 reply_to_msg,
2012 reply_context: reply_context.map(|rc| Box::new(ReplyContextEvent::from(rc))),
2013 forward_info: forward_info.map(ForwardInfoEvent::from),
2014 }
2015 }
2016
2017 /// Creates a FileReceiveFailed event.
2018 pub fn file_receive_failed(
2019 file_id: String,
2020 file_name: String,
2021 sender: String,
2022 reason: String,
2023 ) -> Self {
2024 Self::FileReceiveFailed {
2025 file_id,
2026 file_name,
2027 sender,
2028 reason,
2029 }
2030 }
2031
2032 /// Creates a MediaSent event.
2033 pub fn media_sent(file_id: String, content_type: ContentType, recipient: String) -> Self {
2034 Self::MediaSent {
2035 file_id,
2036 content_type: content_type.to_string(),
2037 recipient,
2038 }
2039 }
2040
2041 /// Creates a MediaSendFailed event.
2042 pub fn media_send_failed(file_id: String, recipient: String, reason: String) -> Self {
2043 Self::MediaSendFailed {
2044 file_id,
2045 recipient,
2046 reason,
2047 }
2048 }
2049
2050 /// Creates a MessageDeferred event.
2051 pub fn message_deferred(
2052 message_id: MessageId,
2053 recipient: String,
2054 reason: &'static str,
2055 retry_count: u32,
2056 next_retry_at: Option<i64>,
2057 ) -> Self {
2058 Self::MessageDeferred {
2059 message_id: message_id.as_str(),
2060 recipient,
2061 reason: reason.to_string(),
2062 retry_count,
2063 next_retry_at,
2064 }
2065 }
2066
2067 /// Creates a MessageRetrying event.
2068 pub fn message_retrying(
2069 message_id: MessageId,
2070 recipient: String,
2071 retry_count: u32,
2072 next_retry_at: i64,
2073 ) -> Self {
2074 Self::MessageRetrying {
2075 message_id: message_id.as_str(),
2076 recipient,
2077 retry_count,
2078 next_retry_at,
2079 }
2080 }
2081
2082 /// Creates a MessageUndeliverable event.
2083 pub fn message_undeliverable(
2084 message_id: MessageId,
2085 recipient: String,
2086 reason: &'static str,
2087 file_id: Option<String>,
2088 ) -> Self {
2089 Self::MessageUndeliverable {
2090 message_id: message_id.as_str(),
2091 recipient,
2092 reason: reason.to_string(),
2093 file_id,
2094 }
2095 }
2096
2097 /// Creates a MediaResendRequired event.
2098 pub fn media_resend_required(
2099 file_id: String,
2100 recipient: String,
2101 file_name: String,
2102 file_size: u64,
2103 ) -> Self {
2104 Self::MediaResendRequired {
2105 file_id,
2106 recipient,
2107 file_name,
2108 file_size,
2109 }
2110 }
2111
2112 /// Creates an AckEvicted event.
2113 pub fn ack_evicted(message_id: MessageId, priority: &str, reason: String) -> Self {
2114 Self::AckEvicted {
2115 message_id: message_id.as_str(),
2116 priority: priority.to_string(),
2117 reason,
2118 }
2119 }
2120
2121 /// Creates a FragmentAssemblyEvicted event.
2122 pub fn fragment_assembly_evicted(
2123 message_id: String,
2124 completion_percent: u8,
2125 reason: String,
2126 ) -> Self {
2127 Self::FragmentAssemblyEvicted {
2128 message_id,
2129 completion_percent,
2130 reason,
2131 }
2132 }
2133
2134 /// Creates a RelayDemotedBattery event.
2135 pub fn relay_demoted_battery(battery_level: u8, min_required: u8) -> Self {
2136 Self::RelayDemotedBattery {
2137 battery_level,
2138 min_required,
2139 }
2140 }
2141
2142 /// Creates a SecureSessionEstablished event.
2143 pub fn secure_session_established(
2144 peer_id: String,
2145 group_id: String,
2146 is_session: bool,
2147 initiated_by_local: bool,
2148 ) -> Self {
2149 Self::SecureSessionEstablished {
2150 peer_id,
2151 group_id,
2152 is_session,
2153 initiated_by_local,
2154 }
2155 }
2156
2157 /// Creates a SecureSessionFailed event.
2158 pub fn secure_session_failed(peer_id: String, reason: String) -> Self {
2159 Self::SecureSessionFailed { peer_id, reason }
2160 }
2161
2162 /// Creates a ConvergenceDiag event (1:1 MLS convergence instrumentation).
2163 pub fn convergence_diag(stage: String, peer_id: String, detail: String) -> Self {
2164 Self::ConvergenceDiag {
2165 stage,
2166 peer_id,
2167 detail,
2168 }
2169 }
2170
2171 /// Creates a WelcomeSendAttempted event.
2172 pub fn welcome_send_attempted(
2173 peer_id: String,
2174 message_id: String,
2175 group_id: String,
2176 attempt: u32,
2177 ) -> Self {
2178 Self::WelcomeSendAttempted {
2179 peer_id,
2180 message_id,
2181 group_id,
2182 attempt,
2183 }
2184 }
2185
2186 /// Creates a WelcomeSendSucceeded event.
2187 pub fn welcome_send_succeeded(
2188 peer_id: String,
2189 message_id: String,
2190 group_id: String,
2191 attempt: u32,
2192 ) -> Self {
2193 Self::WelcomeSendSucceeded {
2194 peer_id,
2195 message_id,
2196 group_id,
2197 attempt,
2198 }
2199 }
2200
2201 /// Creates a WelcomeSendFailed event.
2202 #[allow(clippy::too_many_arguments)]
2203 pub fn welcome_send_failed(
2204 peer_id: String,
2205 message_id: String,
2206 group_id: String,
2207 attempt: u32,
2208 reason_code: WelcomeReasonCode,
2209 transport_error: Option<&'static str>,
2210 retryable: bool,
2211 next_retry_at: Option<i64>,
2212 ) -> Self {
2213 Self::WelcomeSendFailed {
2214 peer_id,
2215 message_id,
2216 group_id,
2217 attempt,
2218 reason_code,
2219 transport_error: transport_error.map(str::to_string),
2220 retryable,
2221 next_retry_at,
2222 }
2223 }
2224
2225 /// Creates a WelcomeSendExpired event.
2226 pub fn welcome_send_expired(
2227 peer_id: String,
2228 message_id: String,
2229 attempt: u32,
2230 reason_code: WelcomeReasonCode,
2231 ) -> Self {
2232 Self::WelcomeSendExpired {
2233 peer_id,
2234 message_id,
2235 attempt,
2236 reason_code,
2237 }
2238 }
2239
2240 /// Creates a ConnectionRequestReceived event.
2241 pub fn connection_request_received(
2242 sender: String,
2243 sender_name: String,
2244 timestamp: i64,
2245 key_package: Option<Vec<u8>>,
2246 initial_message: Option<String>,
2247 ) -> Self {
2248 Self::ConnectionRequestReceived {
2249 sender,
2250 sender_name,
2251 timestamp,
2252 key_package,
2253 initial_message,
2254 }
2255 }
2256
2257 /// Creates a ConnectionRequestUndeliverable event.
2258 pub fn connection_request_undeliverable(
2259 recipient: String,
2260 message_id: String,
2261 reason: &'static str,
2262 ) -> Self {
2263 Self::ConnectionRequestUndeliverable {
2264 recipient,
2265 message_id,
2266 reason: reason.to_string(),
2267 }
2268 }
2269
2270 /// Creates a ConnectionAccepted event.
2271 pub fn connection_accepted(
2272 accepted_by: String,
2273 accepted_by_name: String,
2274 timestamp: i64,
2275 key_package: Option<Vec<u8>>,
2276 ) -> Self {
2277 Self::ConnectionAccepted {
2278 accepted_by,
2279 accepted_by_name,
2280 timestamp,
2281 key_package,
2282 }
2283 }
2284
2285 /// Creates a ConnectionRejected event.
2286 pub fn connection_rejected(rejected_by: String) -> Self {
2287 Self::ConnectionRejected { rejected_by }
2288 }
2289
2290 /// Creates a ConnectionRequestCancelled event.
2291 pub fn connection_request_cancelled(cancelled_by: String) -> Self {
2292 Self::ConnectionRequestCancelled { cancelled_by }
2293 }
2294
2295 /// Creates a PresenceUpdated event for a peer-sent `__PRESENCE__`
2296 /// self-report (`source: peer`).
2297 pub fn presence_updated(peer_id: String, status: PresenceStatus, timestamp: i64) -> Self {
2298 Self::PresenceUpdated {
2299 peer_id,
2300 status,
2301 timestamp,
2302 last_seen_ms: None,
2303 source: PresenceSource::Peer,
2304 }
2305 }
2306
2307 /// Creates a PresenceUpdated event carrying a last-seen timestamp
2308 /// (relay-sourced presence, `source: internet`).
2309 pub fn presence_updated_with_last_seen(
2310 peer_id: String,
2311 status: PresenceStatus,
2312 timestamp: i64,
2313 last_seen_ms: Option<i64>,
2314 ) -> Self {
2315 Self::presence_updated_from(
2316 peer_id,
2317 status,
2318 timestamp,
2319 last_seen_ms,
2320 PresenceSource::Internet,
2321 )
2322 }
2323
2324 /// Creates a PresenceUpdated event for a gateway answer, naming which
2325 /// carrier answered.
2326 ///
2327 /// The relay is one gateway among others now, so the source is a
2328 /// parameter rather than a constant. It is still not free-form: a
2329 /// peer-sent self-report goes through [`Self::presence_updated`], which
2330 /// is the only way to produce `PresenceSource::Peer`, so a carrier answer
2331 /// can never be mislabelled as one.
2332 pub fn presence_updated_from(
2333 peer_id: String,
2334 status: PresenceStatus,
2335 timestamp: i64,
2336 last_seen_ms: Option<i64>,
2337 source: PresenceSource,
2338 ) -> Self {
2339 Self::PresenceUpdated {
2340 peer_id,
2341 status,
2342 timestamp,
2343 last_seen_ms,
2344 source,
2345 }
2346 }
2347
2348 /// Creates a TypingIndicatorReceived event.
2349 pub fn typing_indicator_received(
2350 sender: String,
2351 conversation_id: String,
2352 is_typing: bool,
2353 timestamp: i64,
2354 ) -> Self {
2355 Self::TypingIndicatorReceived {
2356 sender,
2357 conversation_id,
2358 is_typing,
2359 timestamp,
2360 }
2361 }
2362
2363 /// Creates a ReadReceiptReceived event.
2364 pub fn read_receipt_received(sender: String, message_ids: Vec<String>, timestamp: i64) -> Self {
2365 Self::ReadReceiptReceived {
2366 sender,
2367 message_ids,
2368 timestamp,
2369 }
2370 }
2371
2372 /// Creates a GroupCreated event.
2373 pub fn group_created(group_id: String, name: String) -> Self {
2374 Self::GroupCreated { group_id, name }
2375 }
2376
2377 /// Creates a GroupMessageReceived event.
2378 #[allow(clippy::too_many_arguments)]
2379 pub fn group_message_received(
2380 group_id: String,
2381 sender: String,
2382 content: String,
2383 timestamp: String,
2384 message_id: String,
2385 reply_to_msg: Option<String>,
2386 forward_info: Option<ForwardInfoEvent>,
2387 media_metadata: Option<MediaMetadata>,
2388 content_type: Option<String>,
2389 ) -> Self {
2390 Self::GroupMessageReceived {
2391 group_id,
2392 sender,
2393 content,
2394 timestamp,
2395 message_id,
2396 reply_to_msg,
2397 forward_info,
2398 media_metadata,
2399 content_type,
2400 }
2401 }
2402
2403 /// Creates a GroupMemberAdded event.
2404 pub fn group_member_added(
2405 group_id: String,
2406 user_id: String,
2407 added_by: String,
2408 group_name: Option<String>,
2409 authorized: Option<bool>,
2410 ) -> Self {
2411 Self::GroupMemberAdded {
2412 group_id,
2413 user_id,
2414 added_by,
2415 group_name,
2416 authorized,
2417 }
2418 }
2419
2420 /// Creates a GroupMemberRemoved event.
2421 pub fn group_member_removed(
2422 group_id: String,
2423 user_id: String,
2424 removed_by: String,
2425 authorized: Option<bool>,
2426 ) -> Self {
2427 Self::GroupMemberRemoved {
2428 group_id,
2429 user_id,
2430 removed_by,
2431 authorized,
2432 }
2433 }
2434
2435 /// Creates a GroupRoleChanged event.
2436 pub fn group_role_changed(
2437 group_id: String,
2438 user_id: String,
2439 new_role: String,
2440 changed_by: String,
2441 ) -> Self {
2442 Self::GroupRoleChanged {
2443 group_id,
2444 user_id,
2445 new_role,
2446 changed_by,
2447 }
2448 }
2449
2450 /// Creates a GroupRenamed event.
2451 pub fn group_renamed(
2452 group_id: String,
2453 new_name: String,
2454 old_name: Option<String>,
2455 renamed_by: String,
2456 ) -> Self {
2457 Self::GroupRenamed {
2458 group_id,
2459 new_name,
2460 old_name,
2461 renamed_by,
2462 }
2463 }
2464
2465 /// Creates a GroupInfo event.
2466 pub fn group_info(
2467 group_id: String,
2468 name: String,
2469 created_by: String,
2470 created_at: String,
2471 members: Vec<GroupInfoMember>,
2472 ) -> Self {
2473 Self::GroupInfo {
2474 group_id,
2475 name,
2476 created_by,
2477 created_at,
2478 members,
2479 }
2480 }
2481
2482 /// Creates a UserGroups event.
2483 pub fn user_groups(groups: Vec<UserGroupSummary>) -> Self {
2484 Self::UserGroups { groups }
2485 }
2486
2487 /// Creates a GroupError event.
2488 ///
2489 /// `reason` must be a locally-minted code, never relay text — callers on
2490 /// the wire path get theirs from `GroupErrorPayload::classify_reason`.
2491 pub fn group_error(reason: String, group_id: Option<String>) -> Self {
2492 Self::GroupError { reason, group_id }
2493 }
2494
2495 /// Creates a GroupRelaySyncChanged event.
2496 pub fn group_relay_sync_changed(group_id: String, synced: bool, reason: &str) -> Self {
2497 Self::GroupRelaySyncChanged {
2498 group_id,
2499 synced,
2500 reason: reason.to_string(),
2501 }
2502 }
2503
2504 /// Creates a GroupMessageSent event.
2505 pub fn group_message_sent(
2506 group_id: String,
2507 message_ids: Vec<String>,
2508 member_count: u32,
2509 ) -> Self {
2510 Self::GroupMessageSent {
2511 group_id,
2512 message_ids,
2513 member_count,
2514 }
2515 }
2516
2517 /// Creates a GroupMessagePartialFailure event.
2518 pub fn group_message_partial_failure(
2519 group_id: String,
2520 failed_members: Vec<String>,
2521 succeeded_members: Vec<String>,
2522 ) -> Self {
2523 Self::GroupMessagePartialFailure {
2524 group_id,
2525 failed_members,
2526 succeeded_members,
2527 }
2528 }
2529
2530 /// Creates a GroupMessageDeliveryReport event.
2531 pub fn group_message_delivery_report(
2532 group_id: String,
2533 message_id: String,
2534 delivered: Vec<String>,
2535 pushed: Vec<String>,
2536 missed_reissued: Vec<String>,
2537 ) -> Self {
2538 Self::GroupMessageDeliveryReport {
2539 group_id,
2540 message_id,
2541 delivered,
2542 pushed,
2543 missed_reissued,
2544 }
2545 }
2546
2547 /// Creates a GroupRichExtrasDropped event.
2548 pub fn group_rich_extras_dropped(group_id: String, unknown_members: Vec<String>) -> Self {
2549 Self::GroupRichExtrasDropped {
2550 group_id,
2551 unknown_members,
2552 }
2553 }
2554
2555 /// Creates a GroupUnauthorizedMembershipChange event.
2556 pub fn group_unauthorized_membership_change(
2557 group_id: String,
2558 committer: String,
2559 added: Vec<String>,
2560 removed: Vec<String>,
2561 reason: String,
2562 enforced: bool,
2563 ) -> Self {
2564 Self::GroupUnauthorizedMembershipChange {
2565 group_id,
2566 committer,
2567 added,
2568 removed,
2569 reason,
2570 enforced,
2571 }
2572 }
2573
2574 /// Creates a GroupEpochForkDetected event.
2575 pub fn group_epoch_fork_detected(group_id: String, local_epoch: Option<u64>) -> Self {
2576 Self::GroupEpochForkDetected {
2577 group_id,
2578 local_epoch,
2579 }
2580 }
2581
2582 /// Creates a GroupEpochForkResolved event.
2583 pub fn group_epoch_fork_resolved(
2584 group_id: String,
2585 resolved_epoch: u64,
2586 failed_members: Vec<String>,
2587 ) -> Self {
2588 Self::GroupEpochForkResolved {
2589 group_id,
2590 resolved_epoch,
2591 failed_members,
2592 }
2593 }
2594
2595 /// Creates a ServiceDiscovered event.
2596 pub fn service_discovered(
2597 query_id: String,
2598 service_id: String,
2599 version: String,
2600 provider_peer_id: String,
2601 capabilities: HashMap<String, String>,
2602 hop_count: u8,
2603 ) -> Self {
2604 Self::ServiceDiscovered {
2605 query_id,
2606 service_id,
2607 version,
2608 provider_peer_id,
2609 capabilities,
2610 hop_count,
2611 }
2612 }
2613
2614 /// Creates a ServiceRequestReceived event.
2615 pub fn service_request_received(
2616 request_id: String,
2617 service_id: String,
2618 method: String,
2619 body: String,
2620 sender: String,
2621 ) -> Self {
2622 Self::ServiceRequestReceived {
2623 request_id,
2624 service_id,
2625 method,
2626 body,
2627 sender,
2628 }
2629 }
2630
2631 /// Creates a ServiceResponseReceived event.
2632 pub fn service_response_received(
2633 request_id: String,
2634 service_id: String,
2635 status: String,
2636 body: String,
2637 provider_peer_id: String,
2638 ) -> Self {
2639 Self::ServiceResponseReceived {
2640 request_id,
2641 service_id,
2642 status,
2643 body,
2644 provider_peer_id,
2645 }
2646 }
2647
2648 /// Creates a DorsScoreUpdated event (DORS observability).
2649 pub fn dors_score_updated(scores: Vec<(String, f32)>) -> Self {
2650 Self::DorsScoreUpdated { scores }
2651 }
2652
2653 /// Creates a DorsTransportSelected event (DORS observability).
2654 pub fn dors_transport_selected(
2655 from: Option<String>,
2656 transport: String,
2657 reason_code: DorsReasonCode,
2658 score: f32,
2659 ) -> Self {
2660 Self::DorsTransportSelected {
2661 from,
2662 transport,
2663 reason_code,
2664 score,
2665 }
2666 }
2667
2668 /// Creates a DorsTransportSwitched event (DORS observability).
2669 /// Emitted only when active transport actually changes after a successful send.
2670 pub fn dors_transport_switched(
2671 from: Option<String>,
2672 to: String,
2673 reason_code: DorsReasonCode,
2674 reason_detail: Option<String>,
2675 ) -> Self {
2676 Self::DorsTransportSwitched {
2677 from,
2678 to,
2679 reason_code,
2680 reason_detail,
2681 }
2682 }
2683
2684 /// Creates a DorsEscalationTriggered event (DORS observability).
2685 /// Use phase Triggered at the DORS trigger boundary, Applied when fallback succeeds.
2686 pub fn dors_escalation_triggered(
2687 phase: DorsEscalationPhase,
2688 from: String,
2689 to: String,
2690 reason_code: DorsEscalationReasonCode,
2691 reason_detail: Option<String>,
2692 ) -> Self {
2693 Self::DorsEscalationTriggered {
2694 phase,
2695 from,
2696 to,
2697 reason_code,
2698 reason_detail,
2699 }
2700 }
2701
2702 /// Creates a SecurityWarning event.
2703 pub fn security_warning(
2704 peer_id: String,
2705 reason_code: SecurityWarningCode,
2706 reason: String,
2707 ) -> Self {
2708 Self::SecurityWarning {
2709 peer_id,
2710 reason_code,
2711 reason,
2712 }
2713 }
2714
2715 /// Creates a MessageRelayed event.
2716 pub fn message_relayed(
2717 message_id: String,
2718 sender: String,
2719 recipient: String,
2720 hop_count: u8,
2721 remaining_ttl: u8,
2722 ) -> Self {
2723 Self::MessageRelayed {
2724 message_id,
2725 sender,
2726 recipient,
2727 hop_count,
2728 remaining_ttl,
2729 }
2730 }
2731
2732 /// Creates a UserBlocked event.
2733 pub fn user_blocked(user_id: String) -> Self {
2734 Self::UserBlocked { user_id }
2735 }
2736
2737 /// Creates a UserUnblocked event.
2738 pub fn user_unblocked(user_id: String) -> Self {
2739 Self::UserUnblocked { user_id }
2740 }
2741
2742 /// Converts the event to JSON.
2743 pub fn to_json(&self) -> Result<String, serde_json::Error> {
2744 serde_json::to_string(self)
2745 }
2746
2747 /// Parses an event from JSON.
2748 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
2749 serde_json::from_str(json)
2750 }
2751
2752 /// Returns the stable `snake.dot.case` telemetry name for this event.
2753 ///
2754 /// Names are compatible with OpenTelemetry naming conventions and are
2755 /// considered stable across minor versions of the SDK.
2756 pub fn telemetry_name(&self) -> &'static str {
2757 match self {
2758 Self::MessageSent { .. } => "protocol.message.sent",
2759 Self::MessageReceived { .. } => "protocol.message.received",
2760 Self::MessageDelivered { .. } => "protocol.message.delivered",
2761 Self::MessageFailed { .. } => "protocol.message.failed",
2762 Self::MessageDecryptionFailed { .. } => "protocol.message.decryption_failed",
2763 Self::TransportSwitched { .. } => "protocol.transport.switched",
2764 Self::RelayPromoted { .. } => "protocol.relay.promoted",
2765 Self::RelayDemoted { .. } => "protocol.relay.demoted",
2766 Self::NeighborDiscovered { .. } => "protocol.neighbor.discovered",
2767 Self::NeighborLost { .. } => "protocol.neighbor.lost",
2768 Self::IdentityReady { .. } => "protocol.identity.ready",
2769 Self::NetworkMetrics { .. } => "protocol.network.metrics",
2770 Self::FileProgress { .. } => "protocol.file.progress",
2771 Self::FileReceived { .. } => "protocol.file.received",
2772 Self::FileReceiveFailed { .. } => "protocol.file.receive_failed",
2773 Self::MediaSent { .. } => "protocol.media.sent",
2774 Self::MediaSendFailed { .. } => "protocol.media.send_failed",
2775 Self::MessageDeferred { .. } => "protocol.message.deferred",
2776 Self::MessageRetrying { .. } => "protocol.message.retrying",
2777 Self::MessageUndeliverable { .. } => "protocol.message.undeliverable",
2778 Self::MediaResendRequired { .. } => "protocol.media.resend_required",
2779 Self::AckEvicted { .. } => "protocol.ack.evicted",
2780 Self::FragmentAssemblyEvicted { .. } => "protocol.fragment.assembly_evicted",
2781 Self::RelayDemotedBattery { .. } => "protocol.relay.demoted_battery",
2782 Self::SecureSessionEstablished { .. } => "protocol.secure_session.established",
2783 Self::SecureSessionFailed { .. } => "protocol.secure_session.failed",
2784 Self::ConvergenceDiag { .. } => "protocol.convergence.diag",
2785 Self::WelcomeSendAttempted { .. } => "protocol.welcome.send_attempted",
2786 Self::WelcomeSendSucceeded { .. } => "protocol.welcome.send_succeeded",
2787 Self::WelcomeSendFailed { .. } => "protocol.welcome.send_failed",
2788 Self::WelcomeSendExpired { .. } => "protocol.welcome.send_expired",
2789 Self::ConnectionRequestReceived { .. } => "protocol.connection.request_received",
2790 Self::ConnectionRequestUndeliverable { .. } => {
2791 "protocol.connection.request_undeliverable"
2792 }
2793 Self::ConnectionAccepted { .. } => "protocol.connection.accepted",
2794 Self::ConnectionRejected { .. } => "protocol.connection.rejected",
2795 Self::ConnectionRequestCancelled { .. } => "protocol.connection.request_cancelled",
2796 Self::GroupCreated { .. } => "protocol.group.created",
2797 Self::GroupMessageReceived { .. } => "protocol.group.message_received",
2798 Self::GroupMemberAdded { .. } => "protocol.group.member_added",
2799 Self::GroupMemberRemoved { .. } => "protocol.group.member_removed",
2800 Self::GroupInfo { .. } => "protocol.group.info",
2801 Self::UserGroups { .. } => "protocol.group.user_groups",
2802 Self::GroupError { .. } => "protocol.group.error",
2803 Self::GroupRelaySyncChanged { .. } => "protocol.group.relay_sync_changed",
2804 Self::UsernameResolved { .. } => "protocol.username.resolved",
2805 Self::GroupMessageSent { .. } => "protocol.group.message_sent",
2806 Self::GroupMessagePartialFailure { .. } => "protocol.group.message_partial_failure",
2807 Self::GroupMessageDeliveryReport { .. } => "protocol.group.delivery_report",
2808 Self::GroupRichExtrasDropped { .. } => "protocol.group.rich_extras_dropped",
2809 Self::GroupUnauthorizedMembershipChange { .. } => {
2810 "protocol.group.unauthorized_membership_change"
2811 }
2812 Self::GroupEpochForkDetected { .. } => "protocol.group.epoch_fork_detected",
2813 Self::GroupEpochForkResolved { .. } => "protocol.group.epoch_fork_resolved",
2814 Self::GroupRoleChanged { .. } => "protocol.group.role_changed",
2815 Self::GroupRenamed { .. } => "protocol.group.renamed",
2816 Self::ServiceDiscovered { .. } => "protocol.service.discovered",
2817 Self::ServiceRequestReceived { .. } => "protocol.service.request_received",
2818 Self::ServiceResponseReceived { .. } => "protocol.service.response_received",
2819 Self::PresenceUpdated { .. } => "protocol.presence.updated",
2820 Self::TypingIndicatorReceived { .. } => "protocol.typing.received",
2821 Self::ReadReceiptReceived { .. } => "protocol.read_receipt.received",
2822 Self::DorsScoreUpdated { .. } => "protocol.dors.score_updated",
2823 Self::DorsTransportSelected { .. } => "protocol.dors.transport_selected",
2824 Self::DorsTransportSwitched { .. } => "protocol.dors.transport_switched",
2825 Self::DorsEscalationTriggered { .. } => "protocol.dors.escalation_triggered",
2826 Self::SecurityWarning { .. } => "protocol.security.warning",
2827 Self::MessageRelayed { .. } => "protocol.message.relayed",
2828 Self::UserBlocked { .. } => "protocol.user.blocked",
2829 Self::UserUnblocked { .. } => "protocol.user.unblocked",
2830 Self::DataChanged { .. } => "protocol.data.changed",
2831 Self::DataDocSizeWarning { .. } => "protocol.data.doc_size_warning",
2832 Self::DataAttachmentRequested { .. } => "protocol.data.attachment_requested",
2833 Self::DataAttachmentReceived { .. } => "protocol.data.attachment_received",
2834 Self::DataAttachmentUnavailable { .. } => "protocol.data.attachment_unavailable",
2835 Self::DataDocUnsyncable { .. } => "protocol.data.doc_unsyncable",
2836 }
2837 }
2838}
2839
2840impl From<ServiceEvent> for Event {
2841 fn from(event: ServiceEvent) -> Self {
2842 match event {
2843 ServiceEvent::ServiceDiscovered {
2844 query_id,
2845 service_id,
2846 version,
2847 provider_peer_id,
2848 capabilities,
2849 hop_count,
2850 } => Self::ServiceDiscovered {
2851 query_id,
2852 service_id,
2853 version,
2854 provider_peer_id,
2855 capabilities,
2856 hop_count,
2857 },
2858 ServiceEvent::ServiceRequestReceived {
2859 request_id,
2860 service_id,
2861 method,
2862 body,
2863 sender,
2864 } => Self::ServiceRequestReceived {
2865 request_id,
2866 service_id,
2867 method,
2868 body,
2869 sender,
2870 },
2871 ServiceEvent::ServiceResponseReceived {
2872 request_id,
2873 service_id,
2874 status,
2875 body,
2876 provider_peer_id,
2877 } => Self::ServiceResponseReceived {
2878 request_id,
2879 service_id,
2880 status,
2881 body,
2882 provider_peer_id,
2883 },
2884 }
2885 }
2886}
2887
2888/// Custom Debug implementation that redacts sensitive fields to prevent
2889/// accidental logging of message content and other potentially sensitive data.
2890impl fmt::Debug for Event {
2891 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2892 match self {
2893 Self::MessageSent {
2894 message_id,
2895 sender: _,
2896 recipient: _,
2897 content,
2898 priority,
2899 requires_ack,
2900 timestamp,
2901 lamport_clock,
2902 forward_info,
2903 } => f
2904 .debug_struct("MessageSent")
2905 .field("message_id", message_id)
2906 .field("sender", &"[REDACTED]")
2907 .field("recipient", &"[REDACTED]")
2908 .field("content", &format!("[REDACTED {} bytes]", content.len()))
2909 .field("priority", priority)
2910 .field("requires_ack", requires_ack)
2911 .field("timestamp", timestamp)
2912 .field("lamport_clock", lamport_clock)
2913 .field("forward_info", &forward_info.is_some())
2914 .finish(),
2915 Self::MessageReceived {
2916 message_id,
2917 sender: _,
2918 recipient: _,
2919 content,
2920 hop_count,
2921 transport,
2922 timestamp,
2923 lamport_clock,
2924 reply_to_msg: _,
2925 reply_context,
2926 content_type,
2927 media_metadata: _,
2928 forward_info,
2929 encrypted,
2930 } => f
2931 .debug_struct("MessageReceived")
2932 .field("message_id", message_id)
2933 .field("sender", &"[REDACTED]")
2934 .field("recipient", &"[REDACTED]")
2935 .field("content", &format!("[REDACTED {} bytes]", content.len()))
2936 .field("hop_count", hop_count)
2937 .field("transport", transport)
2938 .field("timestamp", timestamp)
2939 .field("lamport_clock", lamport_clock)
2940 .field("reply_to_msg", &"[REDACTED]")
2941 .field("reply_context", &reply_context.is_some())
2942 .field("content_type", content_type)
2943 .field("forward_info", &forward_info.is_some())
2944 .field("encrypted", encrypted)
2945 .finish(),
2946 Self::MessageDelivered {
2947 message_id,
2948 latency_ms,
2949 hop_count,
2950 transport,
2951 } => f
2952 .debug_struct("MessageDelivered")
2953 .field("message_id", message_id)
2954 .field("latency_ms", latency_ms)
2955 .field("hop_count", hop_count)
2956 .field("transport", transport)
2957 .finish(),
2958 Self::MessageFailed {
2959 message_id,
2960 reason,
2961 retry_count,
2962 } => f
2963 .debug_struct("MessageFailed")
2964 .field("message_id", message_id)
2965 .field("reason", reason)
2966 .field("retry_count", retry_count)
2967 .finish(),
2968 Self::MessageDecryptionFailed {
2969 message_id,
2970 sender: _,
2971 code,
2972 reason,
2973 } => f
2974 .debug_struct("MessageDecryptionFailed")
2975 .field("message_id", message_id)
2976 .field("sender", &"[REDACTED]")
2977 .field("code", code)
2978 .field("reason", reason)
2979 .finish(),
2980 Self::TransportSwitched { from, to, reason } => f
2981 .debug_struct("TransportSwitched")
2982 .field("from", from)
2983 .field("to", to)
2984 .field("reason", reason)
2985 .finish(),
2986 Self::RelayPromoted {
2987 connection_count,
2988 battery_level,
2989 } => f
2990 .debug_struct("RelayPromoted")
2991 .field("connection_count", connection_count)
2992 .field("battery_level", battery_level)
2993 .finish(),
2994 Self::RelayDemoted { reason } => f
2995 .debug_struct("RelayDemoted")
2996 .field("reason", reason)
2997 .finish(),
2998 Self::NeighborDiscovered {
2999 peer_id: _,
3000 transport,
3001 rssi,
3002 } => f
3003 .debug_struct("NeighborDiscovered")
3004 .field("peer_id", &"[REDACTED]")
3005 .field("transport", transport)
3006 .field("rssi", rssi)
3007 .finish(),
3008 Self::NeighborLost { peer_id: _ } => f
3009 .debug_struct("NeighborLost")
3010 .field("peer_id", &"[REDACTED]")
3011 .finish(),
3012 // Redacted like any other identifier: this one names *this* device,
3013 // which is the strongest correlator a log can carry.
3014 Self::IdentityReady { address: _ } => f
3015 .debug_struct("IdentityReady")
3016 .field("address", &"[REDACTED]")
3017 .finish(),
3018 Self::NetworkMetrics {
3019 neighbor_count,
3020 relay_count,
3021 delivery_ratio,
3022 avg_latency_ms,
3023 } => f
3024 .debug_struct("NetworkMetrics")
3025 .field("neighbor_count", neighbor_count)
3026 .field("relay_count", relay_count)
3027 .field("delivery_ratio", delivery_ratio)
3028 .field("avg_latency_ms", avg_latency_ms)
3029 .finish(),
3030 Self::FileProgress {
3031 file_id,
3032 chunks_sent,
3033 total_chunks,
3034 percentage,
3035 } => f
3036 .debug_struct("FileProgress")
3037 .field("file_id", file_id)
3038 .field("chunks_sent", chunks_sent)
3039 .field("total_chunks", total_chunks)
3040 .field("percentage", percentage)
3041 .finish(),
3042 Self::FileReceived {
3043 file_id,
3044 file_name,
3045 file_size,
3046 sender: _,
3047 content_type,
3048 media_metadata: _,
3049 file_data,
3050 timestamp,
3051 caption,
3052 reply_to_msg: _,
3053 reply_context,
3054 forward_info,
3055 } => f
3056 .debug_struct("FileReceived")
3057 .field("file_id", file_id)
3058 .field("file_name", file_name)
3059 .field("file_size", file_size)
3060 .field("sender", &"[REDACTED]")
3061 .field("content_type", content_type)
3062 .field("file_data", &format!("[{} bytes base64]", file_data.len()))
3063 .field("timestamp", timestamp)
3064 .field("caption", &caption.is_some())
3065 .field("reply_to_msg", &"[REDACTED]")
3066 .field("reply_context", &reply_context.is_some())
3067 .field("forward_info", &forward_info.is_some())
3068 .finish(),
3069 Self::FileReceiveFailed {
3070 file_id,
3071 file_name,
3072 sender: _,
3073 reason,
3074 } => f
3075 .debug_struct("FileReceiveFailed")
3076 .field("file_id", file_id)
3077 .field("file_name", file_name)
3078 .field("sender", &"[REDACTED]")
3079 .field("reason", reason)
3080 .finish(),
3081 Self::MediaSent {
3082 file_id,
3083 content_type,
3084 recipient: _,
3085 } => f
3086 .debug_struct("MediaSent")
3087 .field("file_id", file_id)
3088 .field("content_type", content_type)
3089 .field("recipient", &"[REDACTED]")
3090 .finish(),
3091 Self::MediaSendFailed {
3092 file_id,
3093 recipient: _,
3094 reason,
3095 } => f
3096 .debug_struct("MediaSendFailed")
3097 .field("file_id", file_id)
3098 .field("recipient", &"[REDACTED]")
3099 .field("reason", reason)
3100 .finish(),
3101 Self::MessageDeferred {
3102 message_id,
3103 recipient: _,
3104 reason,
3105 retry_count,
3106 next_retry_at,
3107 } => f
3108 .debug_struct("MessageDeferred")
3109 .field("message_id", message_id)
3110 .field("recipient", &"[REDACTED]")
3111 .field("reason", reason)
3112 .field("retry_count", retry_count)
3113 .field("next_retry_at", next_retry_at)
3114 .finish(),
3115 Self::MessageRetrying {
3116 message_id,
3117 recipient: _,
3118 retry_count,
3119 next_retry_at,
3120 } => f
3121 .debug_struct("MessageRetrying")
3122 .field("message_id", message_id)
3123 .field("recipient", &"[REDACTED]")
3124 .field("retry_count", retry_count)
3125 .field("next_retry_at", next_retry_at)
3126 .finish(),
3127 Self::MessageUndeliverable {
3128 message_id,
3129 recipient: _,
3130 reason,
3131 file_id,
3132 } => f
3133 .debug_struct("MessageUndeliverable")
3134 .field("message_id", message_id)
3135 .field("recipient", &"[REDACTED]")
3136 .field("reason", reason)
3137 .field("file_id", file_id)
3138 .finish(),
3139 Self::MediaResendRequired {
3140 file_id,
3141 recipient: _,
3142 file_name,
3143 file_size,
3144 } => f
3145 .debug_struct("MediaResendRequired")
3146 .field("file_id", file_id)
3147 .field("recipient", &"[REDACTED]")
3148 .field("file_name", file_name)
3149 .field("file_size", file_size)
3150 .finish(),
3151 Self::AckEvicted {
3152 message_id,
3153 priority,
3154 reason,
3155 } => f
3156 .debug_struct("AckEvicted")
3157 .field("message_id", message_id)
3158 .field("priority", priority)
3159 .field("reason", reason)
3160 .finish(),
3161 Self::FragmentAssemblyEvicted {
3162 message_id,
3163 completion_percent,
3164 reason,
3165 } => f
3166 .debug_struct("FragmentAssemblyEvicted")
3167 .field("message_id", message_id)
3168 .field("completion_percent", completion_percent)
3169 .field("reason", reason)
3170 .finish(),
3171 Self::RelayDemotedBattery {
3172 battery_level,
3173 min_required,
3174 } => f
3175 .debug_struct("RelayDemotedBattery")
3176 .field("battery_level", battery_level)
3177 .field("min_required", min_required)
3178 .finish(),
3179 Self::SecureSessionEstablished {
3180 peer_id: _,
3181 group_id,
3182 is_session,
3183 initiated_by_local,
3184 } => f
3185 .debug_struct("SecureSessionEstablished")
3186 .field("peer_id", &"[REDACTED]")
3187 .field("group_id", group_id)
3188 .field("is_session", is_session)
3189 .field("initiated_by_local", initiated_by_local)
3190 .finish(),
3191 Self::SecureSessionFailed { peer_id: _, reason } => f
3192 .debug_struct("SecureSessionFailed")
3193 .field("peer_id", &"[REDACTED]")
3194 .field("reason", reason)
3195 .finish(),
3196 Self::ConvergenceDiag {
3197 stage,
3198 peer_id: _,
3199 detail,
3200 } => f
3201 .debug_struct("ConvergenceDiag")
3202 .field("stage", stage)
3203 .field("peer_id", &"[REDACTED]")
3204 .field("detail", detail)
3205 .finish(),
3206 Self::WelcomeSendAttempted {
3207 peer_id: _,
3208 message_id,
3209 group_id,
3210 attempt,
3211 } => f
3212 .debug_struct("WelcomeSendAttempted")
3213 .field("peer_id", &"[REDACTED]")
3214 .field("message_id", message_id)
3215 .field("group_id", group_id)
3216 .field("attempt", attempt)
3217 .finish(),
3218 Self::WelcomeSendSucceeded {
3219 peer_id: _,
3220 message_id,
3221 group_id,
3222 attempt,
3223 } => f
3224 .debug_struct("WelcomeSendSucceeded")
3225 .field("peer_id", &"[REDACTED]")
3226 .field("message_id", message_id)
3227 .field("group_id", group_id)
3228 .field("attempt", attempt)
3229 .finish(),
3230 Self::WelcomeSendFailed {
3231 peer_id: _,
3232 message_id,
3233 group_id,
3234 attempt,
3235 reason_code,
3236 transport_error,
3237 retryable,
3238 next_retry_at,
3239 } => f
3240 .debug_struct("WelcomeSendFailed")
3241 .field("peer_id", &"[REDACTED]")
3242 .field("message_id", message_id)
3243 .field("group_id", group_id)
3244 .field("attempt", attempt)
3245 .field("reason_code", reason_code)
3246 .field("transport_error", transport_error)
3247 .field("retryable", retryable)
3248 .field("next_retry_at", next_retry_at)
3249 .finish(),
3250 Self::WelcomeSendExpired {
3251 peer_id: _,
3252 message_id,
3253 attempt,
3254 reason_code,
3255 } => f
3256 .debug_struct("WelcomeSendExpired")
3257 .field("peer_id", &"[REDACTED]")
3258 .field("message_id", message_id)
3259 .field("attempt", attempt)
3260 .field("reason_code", reason_code)
3261 .finish(),
3262 Self::ConnectionRequestReceived {
3263 sender: _,
3264 sender_name: _,
3265 timestamp,
3266 key_package,
3267 initial_message,
3268 } => f
3269 .debug_struct("ConnectionRequestReceived")
3270 .field("sender", &"[REDACTED]")
3271 .field("sender_name", &"[REDACTED]")
3272 .field("timestamp", timestamp)
3273 .field("has_key_package", &key_package.is_some())
3274 .field("has_initial_message", &initial_message.is_some())
3275 .finish(),
3276 Self::ConnectionRequestUndeliverable {
3277 recipient: _,
3278 message_id,
3279 reason,
3280 } => f
3281 .debug_struct("ConnectionRequestUndeliverable")
3282 .field("recipient", &"[REDACTED]")
3283 .field("message_id", message_id)
3284 .field("reason", reason)
3285 .finish(),
3286 Self::ConnectionAccepted {
3287 accepted_by: _,
3288 accepted_by_name: _,
3289 timestamp,
3290 key_package,
3291 } => f
3292 .debug_struct("ConnectionAccepted")
3293 .field("accepted_by", &"[REDACTED]")
3294 .field("accepted_by_name", &"[REDACTED]")
3295 .field("timestamp", timestamp)
3296 .field("has_key_package", &key_package.is_some())
3297 .finish(),
3298 Self::ConnectionRejected { rejected_by: _ } => f
3299 .debug_struct("ConnectionRejected")
3300 .field("rejected_by", &"[REDACTED]")
3301 .finish(),
3302 Self::ConnectionRequestCancelled { cancelled_by: _ } => f
3303 .debug_struct("ConnectionRequestCancelled")
3304 .field("cancelled_by", &"[REDACTED]")
3305 .finish(),
3306 Self::ServiceDiscovered {
3307 query_id,
3308 service_id,
3309 version,
3310 provider_peer_id: _,
3311 capabilities,
3312 hop_count,
3313 } => f
3314 .debug_struct("ServiceDiscovered")
3315 .field("query_id", query_id)
3316 .field("service_id", service_id)
3317 .field("version", version)
3318 .field("provider_peer_id", &"[REDACTED]")
3319 .field("capabilities_count", &capabilities.len())
3320 .field("hop_count", hop_count)
3321 .finish(),
3322 Self::ServiceRequestReceived {
3323 request_id,
3324 service_id,
3325 method,
3326 body,
3327 sender: _,
3328 } => f
3329 .debug_struct("ServiceRequestReceived")
3330 .field("request_id", request_id)
3331 .field("service_id", service_id)
3332 .field("method", method)
3333 .field("body", &format!("[REDACTED {} bytes]", body.len()))
3334 .field("sender", &"[REDACTED]")
3335 .finish(),
3336 Self::ServiceResponseReceived {
3337 request_id,
3338 service_id,
3339 status,
3340 body,
3341 provider_peer_id: _,
3342 } => f
3343 .debug_struct("ServiceResponseReceived")
3344 .field("request_id", request_id)
3345 .field("service_id", service_id)
3346 .field("status", status)
3347 .field("body", &format!("[REDACTED {} bytes]", body.len()))
3348 .field("provider_peer_id", &"[REDACTED]")
3349 .finish(),
3350 Self::GroupCreated { group_id, name } => f
3351 .debug_struct("GroupCreated")
3352 .field("group_id", group_id)
3353 .field("name", name)
3354 .finish(),
3355 Self::GroupMessageReceived {
3356 group_id,
3357 sender: _,
3358 content,
3359 forward_info,
3360 ..
3361 } => f
3362 .debug_struct("GroupMessageReceived")
3363 .field("group_id", group_id)
3364 .field("sender", &"[REDACTED]")
3365 .field("content", &format!("[REDACTED {} bytes]", content.len()))
3366 .field("forward_info", &forward_info.is_some())
3367 .finish(),
3368 Self::GroupMemberAdded {
3369 group_id,
3370 user_id: _,
3371 added_by: _,
3372 group_name,
3373 authorized,
3374 } => f
3375 .debug_struct("GroupMemberAdded")
3376 .field("group_id", group_id)
3377 .field("user_id", &"[REDACTED]")
3378 .field("added_by", &"[REDACTED]")
3379 .field("group_name", group_name)
3380 .field("authorized", authorized)
3381 .finish(),
3382 Self::GroupMemberRemoved {
3383 group_id,
3384 user_id: _,
3385 removed_by: _,
3386 authorized,
3387 } => f
3388 .debug_struct("GroupMemberRemoved")
3389 .field("group_id", group_id)
3390 .field("user_id", &"[REDACTED]")
3391 .field("removed_by", &"[REDACTED]")
3392 .field("authorized", authorized)
3393 .finish(),
3394 Self::GroupInfo {
3395 group_id,
3396 name,
3397 created_by: _,
3398 created_at,
3399 members,
3400 } => f
3401 .debug_struct("GroupInfo")
3402 .field("group_id", group_id)
3403 .field("name", name)
3404 .field("created_by", &"[REDACTED]")
3405 .field("created_at", created_at)
3406 .field("members_count", &members.len())
3407 .finish(),
3408 Self::UserGroups { groups } => f
3409 .debug_struct("UserGroups")
3410 .field("groups_count", &groups.len())
3411 .finish(),
3412 Self::GroupError { reason, group_id } => f
3413 .debug_struct("GroupError")
3414 .field("reason", reason)
3415 .field("group_id", group_id)
3416 .finish(),
3417 Self::GroupRelaySyncChanged {
3418 group_id,
3419 synced,
3420 reason,
3421 } => f
3422 .debug_struct("GroupRelaySyncChanged")
3423 .field("group_id", group_id)
3424 .field("synced", synced)
3425 .field("reason", reason)
3426 .finish(),
3427 // The username is redacted: it is a human-readable name for a
3428 // person, which is exactly the class of identifier this Debug
3429 // exists to keep out of logs. The counts say everything a reader
3430 // debugging a resolution needs.
3431 Self::UsernameResolved {
3432 username: _,
3433 claims,
3434 rejected,
3435 truncated,
3436 } => f
3437 .debug_struct("UsernameResolved")
3438 .field("username", &"<redacted>")
3439 .field("claim_count", &claims.len())
3440 .field("rejected", rejected)
3441 .field("truncated", truncated)
3442 .finish(),
3443 Self::GroupMessageSent {
3444 group_id,
3445 message_ids,
3446 member_count,
3447 } => f
3448 .debug_struct("GroupMessageSent")
3449 .field("group_id", group_id)
3450 .field("message_count", &message_ids.len())
3451 .field("member_count", member_count)
3452 .finish(),
3453 Self::GroupMessagePartialFailure {
3454 group_id,
3455 failed_members,
3456 succeeded_members,
3457 } => f
3458 .debug_struct("GroupMessagePartialFailure")
3459 .field("group_id", group_id)
3460 .field("failed_count", &failed_members.len())
3461 .field("succeeded_count", &succeeded_members.len())
3462 .finish(),
3463 Self::GroupMessageDeliveryReport {
3464 group_id,
3465 message_id,
3466 delivered,
3467 pushed,
3468 missed_reissued,
3469 } => f
3470 .debug_struct("GroupMessageDeliveryReport")
3471 .field("group_id", group_id)
3472 .field("message_id", message_id)
3473 .field("delivered_count", &delivered.len())
3474 .field("pushed_count", &pushed.len())
3475 .field("missed_reissued_count", &missed_reissued.len())
3476 .finish(),
3477 Self::GroupRichExtrasDropped {
3478 group_id,
3479 unknown_members,
3480 } => f
3481 .debug_struct("GroupRichExtrasDropped")
3482 .field("group_id", group_id)
3483 .field("unknown_count", &unknown_members.len())
3484 .finish(),
3485 Self::GroupUnauthorizedMembershipChange {
3486 group_id,
3487 committer: _,
3488 added,
3489 removed,
3490 reason,
3491 enforced,
3492 } => f
3493 .debug_struct("GroupUnauthorizedMembershipChange")
3494 .field("group_id", group_id)
3495 .field("committer", &"[REDACTED]")
3496 .field("added_count", &added.len())
3497 .field("removed_count", &removed.len())
3498 .field("reason", reason)
3499 .field("enforced", enforced)
3500 .finish(),
3501 Self::GroupEpochForkDetected {
3502 group_id,
3503 local_epoch,
3504 } => f
3505 .debug_struct("GroupEpochForkDetected")
3506 .field("group_id", group_id)
3507 .field("local_epoch", local_epoch)
3508 .finish(),
3509 Self::GroupEpochForkResolved {
3510 group_id,
3511 resolved_epoch,
3512 failed_members,
3513 } => f
3514 .debug_struct("GroupEpochForkResolved")
3515 .field("group_id", group_id)
3516 .field("resolved_epoch", resolved_epoch)
3517 .field("failed_members", failed_members)
3518 .finish(),
3519 Self::PresenceUpdated {
3520 peer_id: _,
3521 status,
3522 timestamp,
3523 last_seen_ms,
3524 source,
3525 } => f
3526 .debug_struct("PresenceUpdated")
3527 .field("peer_id", &"[REDACTED]")
3528 .field("status", status)
3529 .field("timestamp", timestamp)
3530 .field("last_seen_ms", last_seen_ms)
3531 .field("source", source)
3532 .finish(),
3533 Self::TypingIndicatorReceived {
3534 sender: _,
3535 conversation_id: _,
3536 is_typing,
3537 timestamp,
3538 } => f
3539 .debug_struct("TypingIndicatorReceived")
3540 .field("sender", &"[REDACTED]")
3541 .field("conversation_id", &"[REDACTED]")
3542 .field("is_typing", is_typing)
3543 .field("timestamp", timestamp)
3544 .finish(),
3545 Self::ReadReceiptReceived {
3546 sender: _,
3547 message_ids,
3548 timestamp,
3549 } => f
3550 .debug_struct("ReadReceiptReceived")
3551 .field("sender", &"[REDACTED]")
3552 .field("message_count", &message_ids.len())
3553 .field("timestamp", timestamp)
3554 .finish(),
3555 Self::DorsScoreUpdated { scores } => f
3556 .debug_struct("DorsScoreUpdated")
3557 .field("scores", scores)
3558 .finish(),
3559 Self::DorsTransportSelected {
3560 from,
3561 transport,
3562 reason_code,
3563 score,
3564 } => f
3565 .debug_struct("DorsTransportSelected")
3566 .field("from", from)
3567 .field("transport", transport)
3568 .field("reason_code", reason_code)
3569 .field("score", score)
3570 .finish(),
3571 Self::DorsTransportSwitched {
3572 from,
3573 to,
3574 reason_code,
3575 reason_detail,
3576 } => f
3577 .debug_struct("DorsTransportSwitched")
3578 .field("from", from)
3579 .field("to", to)
3580 .field("reason_code", reason_code)
3581 .field("reason_detail", reason_detail)
3582 .finish(),
3583 Self::DorsEscalationTriggered {
3584 phase,
3585 from,
3586 to,
3587 reason_code,
3588 reason_detail,
3589 } => f
3590 .debug_struct("DorsEscalationTriggered")
3591 .field("phase", phase)
3592 .field("from", from)
3593 .field("to", to)
3594 .field("reason_code", reason_code)
3595 .field("reason_detail", reason_detail)
3596 .finish(),
3597 // The one arm that used to print `peer_id` verbatim, and the worst
3598 // one to: a security warning is precisely the event an operator
3599 // dumps into a log while investigating, and the peer it names is
3600 // usually attacker-controlled — an injected frame carries whatever
3601 // sender it likes, so this field doubles as a log-injection
3602 // surface. The telemetry scrubber already hashed it; only `{:?}`
3603 // disagreed.
3604 Self::SecurityWarning {
3605 peer_id: _,
3606 reason_code,
3607 reason,
3608 } => f
3609 .debug_struct("SecurityWarning")
3610 .field("peer_id", &"[REDACTED]")
3611 .field("reason_code", reason_code)
3612 .field("reason", reason)
3613 .finish(),
3614 Self::MessageRelayed {
3615 message_id,
3616 sender: _,
3617 recipient: _,
3618 hop_count,
3619 remaining_ttl,
3620 } => f
3621 .debug_struct("MessageRelayed")
3622 .field("message_id", message_id)
3623 .field("sender", &"[REDACTED]")
3624 .field("recipient", &"[REDACTED]")
3625 .field("hop_count", hop_count)
3626 .field("remaining_ttl", remaining_ttl)
3627 .finish(),
3628 Self::UserBlocked { user_id: _ } => f
3629 .debug_struct("UserBlocked")
3630 .field("user_id", &"[REDACTED]")
3631 .finish(),
3632 Self::UserUnblocked { user_id: _ } => f
3633 .debug_struct("UserUnblocked")
3634 .field("user_id", &"[REDACTED]")
3635 .finish(),
3636 Self::DataChanged {
3637 space_id: _,
3638 doc_id: _,
3639 delta_bytes,
3640 } => f
3641 .debug_struct("DataChanged")
3642 // A space id is the peer or group the space rides on, and a
3643 // document name is application content. Both are identifiers,
3644 // so both redact.
3645 .field("space_id", &"[REDACTED]")
3646 .field("doc_id", &"[REDACTED]")
3647 .field("delta_bytes", delta_bytes)
3648 .finish(),
3649 Self::DataDocSizeWarning {
3650 space_id: _,
3651 doc_id: _,
3652 compacted_bytes,
3653 cap_bytes,
3654 } => f
3655 .debug_struct("DataDocSizeWarning")
3656 .field("space_id", &"[REDACTED]")
3657 .field("doc_id", &"[REDACTED]")
3658 .field("compacted_bytes", compacted_bytes)
3659 .field("cap_bytes", cap_bytes)
3660 .finish(),
3661 Self::DataAttachmentRequested {
3662 space_id: _,
3663 peer_id: _,
3664 hash,
3665 } => f
3666 .debug_struct("DataAttachmentRequested")
3667 .field("space_id", &"[REDACTED]")
3668 .field("peer_id", &"[REDACTED]")
3669 // A blob hash names bytes, not a person, and it is the one
3670 // field that makes a report about a fetch actionable.
3671 .field("hash", hash)
3672 .finish(),
3673 Self::DataAttachmentReceived {
3674 space_id: _,
3675 peer_id: _,
3676 hash,
3677 data,
3678 } => f
3679 .debug_struct("DataAttachmentReceived")
3680 .field("space_id", &"[REDACTED]")
3681 .field("peer_id", &"[REDACTED]")
3682 .field("hash", hash)
3683 // The blob itself is content and never goes near a log.
3684 .field("data", &format!("[{} base64 chars]", data.len()))
3685 .finish(),
3686 Self::DataAttachmentUnavailable {
3687 space_id: _,
3688 peer_id: _,
3689 hash,
3690 reason,
3691 } => f
3692 .debug_struct("DataAttachmentUnavailable")
3693 .field("space_id", &"[REDACTED]")
3694 .field("peer_id", &"[REDACTED]")
3695 .field("hash", hash)
3696 .field("reason", reason)
3697 .finish(),
3698 Self::DataDocUnsyncable {
3699 space_id: _,
3700 doc_id: _,
3701 bytes,
3702 reason,
3703 } => f
3704 .debug_struct("DataDocUnsyncable")
3705 .field("space_id", &"[REDACTED]")
3706 .field("doc_id", &"[REDACTED]")
3707 .field("bytes", bytes)
3708 .field("reason", reason)
3709 .finish(),
3710 Self::GroupRoleChanged {
3711 group_id,
3712 user_id: _,
3713 new_role,
3714 changed_by: _,
3715 } => f
3716 .debug_struct("GroupRoleChanged")
3717 .field("group_id", group_id)
3718 .field("user_id", &"[REDACTED]")
3719 .field("new_role", new_role)
3720 .field("changed_by", &"[REDACTED]")
3721 .finish(),
3722 Self::GroupRenamed {
3723 group_id,
3724 new_name,
3725 old_name: _,
3726 renamed_by: _,
3727 } => f
3728 .debug_struct("GroupRenamed")
3729 .field("group_id", group_id)
3730 .field("new_name", new_name)
3731 .field("renamed_by", &"[REDACTED]")
3732 .finish(),
3733 }
3734 }
3735}
3736
3737#[cfg(test)]
3738mod tests {
3739 use super::*;
3740 use crate::protocol::SEND_FAIL_REASON_RECIPIENT_UNREACHABLE;
3741 use offline_protocol_core::{AppId, Message, MessagePriority, UserId};
3742
3743 #[test]
3744 fn test_message_sent_event() {
3745 let message = Message::builder(
3746 UserId::new("alice").unwrap(),
3747 UserId::new("bob").unwrap(),
3748 AppId::new("app").unwrap(),
3749 )
3750 .content("Hello")
3751 .priority(MessagePriority::High)
3752 .build();
3753
3754 let event = Event::message_sent(&message);
3755
3756 match event {
3757 Event::MessageSent {
3758 message_id,
3759 sender,
3760 recipient,
3761 content,
3762 priority,
3763 requires_ack,
3764 timestamp,
3765 lamport_clock,
3766 forward_info,
3767 } => {
3768 assert_eq!(message_id, message.id.as_str());
3769 assert_eq!(sender, message.sender.as_str());
3770 assert_eq!(recipient, message.recipient.as_str());
3771 assert_eq!(content, message.content);
3772 assert_eq!(priority, "high");
3773 assert!(requires_ack);
3774 assert_eq!(timestamp, message.timestamp.as_millis());
3775 assert_eq!(lamport_clock, message.lamport_clock.value());
3776 assert!(forward_info.is_none());
3777 }
3778 _ => panic!("Wrong event type"),
3779 }
3780 }
3781
3782 #[test]
3783 fn test_message_delivered_event() {
3784 let msg_id = MessageId::new();
3785 let event = Event::message_delivered(msg_id.clone(), 100, 3, TransportType::BLE);
3786
3787 match event {
3788 Event::MessageDelivered {
3789 message_id,
3790 latency_ms,
3791 hop_count,
3792 transport,
3793 } => {
3794 assert_eq!(message_id, msg_id.as_str());
3795 assert_eq!(latency_ms, 100);
3796 assert_eq!(hop_count, 3);
3797 assert_eq!(transport, "ble");
3798 }
3799 _ => panic!("Wrong event type"),
3800 }
3801 }
3802
3803 #[test]
3804 fn test_transport_switched_event() {
3805 let event = Event::transport_switched(
3806 Some(TransportType::BLE),
3807 TransportType::WiFiDirect,
3808 "Poor signal".to_string(),
3809 );
3810
3811 match event {
3812 Event::TransportSwitched { from, to, reason } => {
3813 assert_eq!(from.as_deref(), Some("ble"));
3814 assert_eq!(to, "wifiDirect");
3815 assert_eq!(reason, "Poor signal");
3816 }
3817 _ => panic!("Wrong event type"),
3818 }
3819 }
3820
3821 #[test]
3822 fn test_relay_promoted_event() {
3823 let event = Event::relay_promoted(5, Some(80));
3824
3825 match event {
3826 Event::RelayPromoted {
3827 connection_count,
3828 battery_level,
3829 } => {
3830 assert_eq!(connection_count, 5);
3831 assert_eq!(battery_level, Some(80));
3832 }
3833 _ => panic!("Wrong event type"),
3834 }
3835 }
3836
3837 #[test]
3838 fn test_file_progress_event() {
3839 let event = Event::file_progress("file123".to_string(), 50, 100);
3840
3841 match event {
3842 Event::FileProgress {
3843 file_id,
3844 chunks_sent,
3845 total_chunks,
3846 percentage,
3847 } => {
3848 assert_eq!(file_id, "file123");
3849 assert_eq!(chunks_sent, 50);
3850 assert_eq!(total_chunks, 100);
3851 assert_eq!(percentage, 50);
3852 }
3853 _ => panic!("Wrong event type"),
3854 }
3855 }
3856
3857 #[test]
3858 fn test_event_serialization() {
3859 let event = Event::relay_promoted(5, Some(80));
3860 let json = event.to_json().unwrap();
3861 let deserialized = Event::from_json(&json).unwrap();
3862
3863 match deserialized {
3864 Event::RelayPromoted {
3865 connection_count,
3866 battery_level,
3867 } => {
3868 assert_eq!(connection_count, 5);
3869 assert_eq!(battery_level, Some(80));
3870 }
3871 _ => panic!("Wrong event type after deserialization"),
3872 }
3873 }
3874
3875 /// Pins the JSON wire shape the RN `types.ts` declarations model
3876 /// (`ConnectionRequestUndeliverableEvent` / `initial_message` on
3877 /// `ConnectionRequestReceivedEvent`) — events cross the FFI as JSON, so
3878 /// this contract is otherwise enforced only by convention.
3879 #[test]
3880 fn test_connection_request_event_wire_shapes() {
3881 let event = Event::connection_request_undeliverable(
3882 "bob".to_string(),
3883 "m1".to_string(),
3884 SEND_FAIL_REASON_RECIPIENT_UNREACHABLE,
3885 );
3886 let json: serde_json::Value = serde_json::from_str(&event.to_json().unwrap()).unwrap();
3887 assert_eq!(json["type"], "connection_request_undeliverable");
3888 assert_eq!(json["recipient"], "bob");
3889 assert_eq!(json["message_id"], "m1");
3890 // The bare token, never the relay's `recipient_unreachable: <prose>`.
3891 assert_eq!(json["reason"], "recipient_unreachable");
3892
3893 let received = Event::connection_request_received(
3894 "alice".to_string(),
3895 "Alice".to_string(),
3896 12345,
3897 None,
3898 Some("hey".to_string()),
3899 );
3900 let json: serde_json::Value = serde_json::from_str(&received.to_json().unwrap()).unwrap();
3901 assert_eq!(json["type"], "connection_request_received");
3902 assert_eq!(json["initial_message"], "hey");
3903
3904 // None must omit the key entirely (skip_serializing_if), keeping the
3905 // event byte-shape identical to pre-initial-message builds.
3906 let received = Event::connection_request_received(
3907 "alice".to_string(),
3908 "Alice".to_string(),
3909 12345,
3910 None,
3911 None,
3912 );
3913 assert!(!received.to_json().unwrap().contains("initial_message"));
3914 }
3915
3916 /// Pins the `group_error` wire shape (#349).
3917 ///
3918 /// `group_id` is additive, so a build that never scopes an error must
3919 /// serialize byte-identically to pre-change builds — the same
3920 /// `skip_serializing_if` contract `initial_message` is pinned on above.
3921 #[test]
3922 fn test_group_error_event_wire_shape() {
3923 let scoped = Event::group_error("sync_denied".to_string(), Some("g-1".to_string()));
3924 let json: serde_json::Value = serde_json::from_str(&scoped.to_json().unwrap()).unwrap();
3925 assert_eq!(json["type"], "group_error");
3926 assert_eq!(json["reason"], "sync_denied");
3927 assert_eq!(json["group_id"], "g-1");
3928
3929 let unscoped = Event::group_error("error".to_string(), None);
3930 assert!(!unscoped.to_json().unwrap().contains("group_id"));
3931 }
3932
3933 #[test]
3934 fn test_network_metrics_event() {
3935 let event = Event::network_metrics(10, 3, 0.95, 150);
3936
3937 match event {
3938 Event::NetworkMetrics {
3939 neighbor_count,
3940 relay_count,
3941 delivery_ratio,
3942 avg_latency_ms,
3943 } => {
3944 assert_eq!(neighbor_count, 10);
3945 assert_eq!(relay_count, 3);
3946 assert_eq!(delivery_ratio, 0.95);
3947 assert_eq!(avg_latency_ms, 150);
3948 }
3949 _ => panic!("Wrong event type"),
3950 }
3951 }
3952
3953 #[test]
3954 fn test_file_received_event() {
3955 let event = Event::file_received(
3956 "file123".to_string(),
3957 "photo.jpg".to_string(),
3958 1024000,
3959 "alice".to_string(),
3960 ContentType::Image,
3961 None,
3962 vec![1, 2, 3, 4],
3963 None,
3964 None,
3965 None,
3966 None,
3967 None,
3968 );
3969
3970 match event {
3971 Event::FileReceived {
3972 file_id,
3973 file_name,
3974 file_size,
3975 sender,
3976 content_type,
3977 file_data,
3978 ..
3979 } => {
3980 assert_eq!(file_id, "file123");
3981 assert_eq!(file_name, "photo.jpg");
3982 assert_eq!(file_size, 1024000);
3983 assert_eq!(sender, "alice");
3984 assert_eq!(content_type, "image");
3985 assert!(!file_data.is_empty());
3986 }
3987 _ => panic!("Wrong event type"),
3988 }
3989 }
3990
3991 #[test]
3992 fn test_file_received_event_rich_fields_absent_from_json_when_none() {
3993 // The rich fields are additive on the event's JSON wire form: a
3994 // plain transfer must serialize exactly as it did before they
3995 // existed, so older consumers see no new keys.
3996 let event = Event::file_received(
3997 "file123".to_string(),
3998 "f.bin".to_string(),
3999 4,
4000 "alice".to_string(),
4001 ContentType::File,
4002 None,
4003 vec![1, 2, 3, 4],
4004 None,
4005 None,
4006 None,
4007 None,
4008 None,
4009 );
4010 let json = serde_json::to_value(&event).unwrap();
4011 for key in [
4012 "timestamp",
4013 "caption",
4014 "reply_to_msg",
4015 "reply_context",
4016 "forward_info",
4017 ] {
4018 assert!(json.get(key).is_none(), "{} must be absent when unset", key);
4019 }
4020 }
4021
4022 #[test]
4023 fn test_file_received_event_rich_fields_round_trip() {
4024 use offline_protocol_core::UserId;
4025 let rc = offline_protocol_core::ReplyContext {
4026 sender: UserId::new("carol").unwrap(),
4027 text: "the original".to_string(),
4028 timestamp: None,
4029 reply_media_label: None,
4030 reply_content_type: Some("text".to_string()),
4031 };
4032 let fwd = offline_protocol_core::ForwardInfo {
4033 original_sender: UserId::new("dave").unwrap(),
4034 original_message_id: offline_protocol_core::MessageId::new(),
4035 original_timestamp: offline_protocol_core::Timestamp::now(),
4036 forward_count: 1,
4037 };
4038 let event = Event::file_received(
4039 "file123".to_string(),
4040 "photo.jpg".to_string(),
4041 1024,
4042 "alice".to_string(),
4043 ContentType::Image,
4044 None,
4045 vec![1, 2, 3],
4046 Some(1_700_000_000_000),
4047 Some("look".to_string()),
4048 Some("0192aaaa-bbbb-cccc-dddd-eeeeffff0000".to_string()),
4049 Some(&rc),
4050 Some(&fwd),
4051 );
4052 let json = serde_json::to_string(&event).unwrap();
4053 let parsed: Event = serde_json::from_str(&json).unwrap();
4054 match parsed {
4055 Event::FileReceived {
4056 timestamp,
4057 caption,
4058 reply_to_msg,
4059 reply_context,
4060 forward_info,
4061 ..
4062 } => {
4063 assert_eq!(timestamp, Some(1_700_000_000_000));
4064 assert_eq!(caption.as_deref(), Some("look"));
4065 assert_eq!(
4066 reply_to_msg.as_deref(),
4067 Some("0192aaaa-bbbb-cccc-dddd-eeeeffff0000")
4068 );
4069 let rc = reply_context.expect("reply_context must round-trip");
4070 assert_eq!(rc.sender, "carol");
4071 assert_eq!(rc.text, "the original");
4072 let fwd = forward_info.expect("forward_info must round-trip");
4073 assert_eq!(fwd.original_sender, "dave");
4074 assert_eq!(fwd.forward_count, 1);
4075 }
4076 _ => panic!("Wrong event type"),
4077 }
4078 }
4079
4080 #[test]
4081 fn test_security_warning_code_as_str_matches_serde_wire_form() {
4082 // `SecurityWarningCode::as_str()` is hand-written, while the JSON wire
4083 // form is derived from `#[serde(rename_all = "SCREAMING_SNAKE_CASE")]`.
4084 // JS consumers branch on the wire string (e.g. keying reinstall handling
4085 // off `SENDER_ADDRESS_MISMATCH`), so the two must never drift. Pin every
4086 // variant to its serialized form — a single-variant test would let the
4087 // others rot.
4088 let all = [
4089 SecurityWarningCode::SenderAddressMismatch,
4090 SecurityWarningCode::TransportIdentityMismatch,
4091 SecurityWarningCode::ControlSignatureInvalid,
4092 SecurityWarningCode::UnsignedControlRejected,
4093 SecurityWarningCode::MediaSenderGroupMismatch,
4094 SecurityWarningCode::PlaintextSend,
4095 SecurityWarningCode::PlaintextReceiveRejected,
4096 SecurityWarningCode::SessionSenderGroupMismatch,
4097 SecurityWarningCode::SessionRekeyTriggered,
4098 SecurityWarningCode::NostrKeyPackageSlotExhausted,
4099 SecurityWarningCode::PushKeyPackagePoolExhausted,
4100 SecurityWarningCode::RelayAddressBindingMismatch,
4101 SecurityWarningCode::RelayAddressDeclarationRefused,
4102 SecurityWarningCode::GroupLeafIdentityUnproven,
4103 SecurityWarningCode::StaleControlFrame,
4104 SecurityWarningCode::GatewayAddressBindingMismatch,
4105 SecurityWarningCode::GatewayAddressDeclarationRefused,
4106 ];
4107 for code in all {
4108 // serde renders a unit enum variant as a quoted JSON string.
4109 let wire = serde_json::to_string(&code).unwrap();
4110 assert_eq!(
4111 format!("\"{}\"", code.as_str()),
4112 wire,
4113 "as_str() drifted from the serde wire form for {code:?}",
4114 );
4115 // Exhaustiveness guard (no wildcard): adding a variant makes this
4116 // match fail to compile until it is also added to `all` above and
4117 // pinned to its wire form.
4118 match code {
4119 SecurityWarningCode::SenderAddressMismatch
4120 | SecurityWarningCode::TransportIdentityMismatch
4121 | SecurityWarningCode::ControlSignatureInvalid
4122 | SecurityWarningCode::UnsignedControlRejected
4123 | SecurityWarningCode::MediaSenderGroupMismatch
4124 | SecurityWarningCode::PlaintextSend
4125 | SecurityWarningCode::PlaintextReceiveRejected
4126 | SecurityWarningCode::SessionSenderGroupMismatch
4127 | SecurityWarningCode::SessionRekeyTriggered
4128 | SecurityWarningCode::NostrKeyPackageSlotExhausted
4129 | SecurityWarningCode::PushKeyPackagePoolExhausted
4130 | SecurityWarningCode::RelayAddressBindingMismatch
4131 | SecurityWarningCode::RelayAddressDeclarationRefused
4132 | SecurityWarningCode::GroupLeafIdentityUnproven
4133 | SecurityWarningCode::StaleControlFrame
4134 | SecurityWarningCode::GatewayAddressBindingMismatch
4135 | SecurityWarningCode::GatewayAddressDeclarationRefused => {}
4136 }
4137 }
4138 }
4139
4140 #[test]
4141 fn test_debug_redacts_every_peer_identifying_field() {
4142 // `SecurityWarning` printed its `peer_id` verbatim while the other
4143 // twelve peer-bearing arms redacted theirs, so a single missed arm was
4144 // invisible: `{:?}` on any *other* event looked correct. Pinning one
4145 // arm would leave the next omission just as invisible, so this
4146 // enumerates every variant carrying a peer-identifying field and
4147 // asserts the sentinel never survives formatting.
4148 //
4149 // Deliberately a *value* assertion rather than a field-name one: the
4150 // question is whether the id reaches the log, not how the arm spells
4151 // its placeholder.
4152 const SENTINEL: &str = "off1qy4aspkf0u8qptc6rlpn9ra8vw5jd9ereq4cwpfs";
4153
4154 let events = vec![
4155 Event::NeighborDiscovered {
4156 peer_id: SENTINEL.to_string(),
4157 transport: "ble".to_string(),
4158 rssi: Some(-40),
4159 },
4160 Event::NeighborLost {
4161 peer_id: SENTINEL.to_string(),
4162 },
4163 Event::SecureSessionEstablished {
4164 peer_id: SENTINEL.to_string(),
4165 group_id: "session:a:b".to_string(),
4166 is_session: true,
4167 initiated_by_local: true,
4168 },
4169 Event::SecureSessionFailed {
4170 peer_id: SENTINEL.to_string(),
4171 reason: "nope".to_string(),
4172 },
4173 Event::ConvergenceDiag {
4174 stage: "stage".to_string(),
4175 peer_id: SENTINEL.to_string(),
4176 detail: "detail".to_string(),
4177 },
4178 Event::WelcomeSendAttempted {
4179 peer_id: SENTINEL.to_string(),
4180 message_id: "m1".to_string(),
4181 group_id: "g1".to_string(),
4182 attempt: 1,
4183 },
4184 Event::WelcomeSendSucceeded {
4185 peer_id: SENTINEL.to_string(),
4186 message_id: "m1".to_string(),
4187 group_id: "g1".to_string(),
4188 attempt: 1,
4189 },
4190 Event::WelcomeSendFailed {
4191 peer_id: SENTINEL.to_string(),
4192 message_id: "m1".to_string(),
4193 group_id: "g1".to_string(),
4194 attempt: 1,
4195 reason_code: WelcomeReasonCode::Timeout,
4196 transport_error: None,
4197 retryable: true,
4198 next_retry_at: None,
4199 },
4200 Event::WelcomeSendExpired {
4201 peer_id: SENTINEL.to_string(),
4202 message_id: "m1".to_string(),
4203 attempt: 3,
4204 reason_code: WelcomeReasonCode::RetryExhausted,
4205 },
4206 Event::ServiceDiscovered {
4207 query_id: "q1".to_string(),
4208 service_id: "s1".to_string(),
4209 version: "1".to_string(),
4210 provider_peer_id: SENTINEL.to_string(),
4211 capabilities: HashMap::new(),
4212 hop_count: 1,
4213 },
4214 Event::ServiceResponseReceived {
4215 request_id: "r1".to_string(),
4216 service_id: "s1".to_string(),
4217 status: "ok".to_string(),
4218 body: "body".to_string(),
4219 provider_peer_id: SENTINEL.to_string(),
4220 },
4221 Event::PresenceUpdated {
4222 peer_id: SENTINEL.to_string(),
4223 status: PresenceStatus::Online,
4224 timestamp: 0,
4225 last_seen_ms: None,
4226 source: PresenceSource::Peer,
4227 },
4228 Event::SecurityWarning {
4229 peer_id: SENTINEL.to_string(),
4230 reason_code: SecurityWarningCode::SenderAddressMismatch,
4231 reason: "mismatch".to_string(),
4232 },
4233 ];
4234
4235 for event in &events {
4236 let rendered = format!("{:?}", event);
4237 assert!(
4238 !rendered.contains(SENTINEL),
4239 "Debug leaked a peer identifier: {rendered}",
4240 );
4241 }
4242 }
4243
4244 /// Mirrors serde's `rename_all = "snake_case"` rule.
4245 fn serde_snake_case(name: &str) -> String {
4246 let mut out = String::new();
4247 for (i, ch) in name.chars().enumerate() {
4248 if ch.is_ascii_uppercase() {
4249 if i > 0 {
4250 out.push('_');
4251 }
4252 out.push(ch.to_ascii_lowercase());
4253 } else {
4254 out.push(ch);
4255 }
4256 }
4257 out
4258 }
4259
4260 /// Drift guard: every `Event` variant must have a typed declaration in the
4261 /// React Native bindings. Events cross UniFFI as tagged JSON, so nothing
4262 /// else fails when `types.ts` lags — the event just arrives untyped and
4263 /// invisible to the `ProtocolEvent` union (7 variants had silently drifted
4264 /// before this guard existed).
4265 #[test]
4266 fn react_native_types_cover_all_event_variants() {
4267 use std::collections::BTreeSet;
4268
4269 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4270 let events_rs = std::fs::read_to_string(manifest_dir.join("src/events.rs")).unwrap();
4271 let types_ts_path = manifest_dir.join("../../bindings/react-native/src/types.ts");
4272 let types_ts = std::fs::read_to_string(&types_ts_path)
4273 .unwrap_or_else(|e| panic!("cannot read {}: {e}", types_ts_path.display()));
4274
4275 // Collect each `Event` variant's serde tag by scanning the enum block:
4276 // variant names sit at 4-space indent, brace depth 1.
4277 let mut rust_tags = BTreeSet::new();
4278 let mut in_enum = false;
4279 let mut depth = 0usize;
4280 for line in events_rs.lines() {
4281 if !in_enum {
4282 if line.starts_with("pub enum Event {") {
4283 in_enum = true;
4284 depth = 1;
4285 }
4286 continue;
4287 }
4288 let trimmed = line.trim();
4289 if trimmed.starts_with("//") || trimmed.starts_with("#[") {
4290 continue;
4291 }
4292 if depth == 1
4293 && line.starts_with(" ")
4294 && !line.starts_with(" ")
4295 && trimmed
4296 .chars()
4297 .next()
4298 .is_some_and(|c| c.is_ascii_uppercase())
4299 {
4300 let name: String = trimmed
4301 .chars()
4302 .take_while(|c| c.is_ascii_alphanumeric())
4303 .collect();
4304 rust_tags.insert(serde_snake_case(&name));
4305 }
4306 depth += line.matches('{').count();
4307 depth = depth.saturating_sub(line.matches('}').count());
4308 if depth == 0 {
4309 break;
4310 }
4311 }
4312 assert!(
4313 rust_tags.len() >= 60,
4314 "enum scan looks broken: only {} variants found",
4315 rust_tags.len()
4316 );
4317
4318 // Event interface discriminants in types.ts: ` type: '<tag>';` at
4319 // exactly 2-space indent (nested object fields sit deeper).
4320 let ts_tags: BTreeSet<String> = types_ts
4321 .lines()
4322 .filter_map(|l| l.strip_prefix(" type: '"))
4323 .filter_map(|rest| rest.strip_suffix("';"))
4324 .map(str::to_string)
4325 .collect();
4326
4327 // Emitted by the native iOS/Android bridges, not the core enum.
4328 let bridge_only: BTreeSet<String> = [
4329 "diagnostic",
4330 "internet_server_message",
4331 "internet_status_changed",
4332 "internet_session_superseded",
4333 // Android only: the mesh foreground service's notification has no
4334 // iOS counterpart.
4335 "mesh_stopped_by_user",
4336 ]
4337 .map(str::to_string)
4338 .into();
4339
4340 let missing: Vec<_> = rust_tags.difference(&ts_tags).collect();
4341 assert!(
4342 missing.is_empty(),
4343 "Event variants missing a typed declaration in bindings/react-native/src/types.ts \
4344 (add the interface AND its ProtocolEvent union entry): {missing:?}"
4345 );
4346
4347 let stale: Vec<_> = ts_tags
4348 .difference(&rust_tags)
4349 .filter(|t| !bridge_only.contains(*t))
4350 .collect();
4351 assert!(
4352 stale.is_empty(),
4353 "types.ts declares event tags with no core Event variant \
4354 (stale, or a new bridge-only event that needs allowlisting here): {stale:?}"
4355 );
4356 }
4357
4358 /// Drift guard for the *payload* side of the same seam: apps switch on
4359 /// `reason_code`, so a code with no entry in the RN union is invisible to
4360 /// exhaustive handling on the app side and silently falls into whatever
4361 /// default a `switch` has. `NOSTR_KEY_PACKAGE_SLOT_EXHAUSTED` had already
4362 /// drifted this way before this guard existed.
4363 #[test]
4364 fn react_native_types_cover_all_security_warning_codes() {
4365 use std::collections::BTreeSet;
4366
4367 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4368 let events_rs = std::fs::read_to_string(manifest_dir.join("src/events.rs")).unwrap();
4369 let types_ts_path = manifest_dir.join("../../bindings/react-native/src/types.ts");
4370 let types_ts = std::fs::read_to_string(&types_ts_path)
4371 .unwrap_or_else(|e| panic!("cannot read {}: {e}", types_ts_path.display()));
4372
4373 // The wire forms are exactly the string literals `as_str` maps to, and
4374 // the test above already pins those to serde's rendering.
4375 // Anchored on the impl block: several enums in this file have an
4376 // identical `as_str` signature, and the first match is a different one.
4377 let as_str_body = events_rs
4378 .split_once("impl SecurityWarningCode {")
4379 .expect("impl SecurityWarningCode not found")
4380 .1
4381 .split_once(" pub fn as_str(&self) -> &'static str {")
4382 .expect("SecurityWarningCode::as_str not found")
4383 .1
4384 .split_once("\n }")
4385 .expect("as_str body not terminated")
4386 .0;
4387 let rust_codes: BTreeSet<String> = as_str_body
4388 .lines()
4389 .filter_map(|l| l.split_once("=> \""))
4390 .filter_map(|(_, rest)| rest.split_once('"'))
4391 .map(|(code, _)| code.to_string())
4392 .collect();
4393
4394 // Negative control: a scan that silently matched nothing would make
4395 // every assertion below vacuously true. The floor tracks the variant
4396 // count loosely — it exists to catch a broken scan, not to freeze the
4397 // taxonomy, so it moves when variants are deliberately added or (as
4398 // when the TOFU codes were deleted) removed.
4399 assert!(
4400 rust_codes.len() >= 11,
4401 "as_str scan looks broken: only {} codes found",
4402 rust_codes.len()
4403 );
4404
4405 let union = types_ts
4406 .split_once("export type SecurityWarningCode =")
4407 .expect("SecurityWarningCode union not found in types.ts")
4408 .1
4409 .split_once(';')
4410 .expect("SecurityWarningCode union not terminated")
4411 .0;
4412 let ts_codes: BTreeSet<String> = union
4413 .lines()
4414 .filter_map(|l| l.trim().strip_prefix("| '"))
4415 .filter_map(|rest| rest.strip_suffix('\''))
4416 .map(str::to_string)
4417 .collect();
4418
4419 let missing: Vec<_> = rust_codes.difference(&ts_codes).collect();
4420 assert!(
4421 missing.is_empty(),
4422 "SecurityWarningCode variants missing from the union in \
4423 bindings/react-native/src/types.ts: {missing:?}"
4424 );
4425
4426 let stale: Vec<_> = ts_codes.difference(&rust_codes).collect();
4427 assert!(
4428 stale.is_empty(),
4429 "types.ts declares security warning codes with no Rust variant: {stale:?}"
4430 );
4431 }
4432
4433 /// The same drift guard for `PresenceSource`, which had none.
4434 ///
4435 /// This union is the discriminator on the one event that merges two
4436 /// unrelated streams, and the documented advice is to filter on it, so an
4437 /// app that switches exhaustively over the TS union silently drops every
4438 /// event carrying a source TypeScript has never heard of. Nothing else
4439 /// catches that: unlike `SecurityWarningCode` there is no `as_str` here,
4440 /// the wire form is serde's alone, and a new variant compiles and ships
4441 /// green on both sides of a seam that disagrees.
4442 #[test]
4443 fn react_native_types_cover_all_presence_sources() {
4444 use std::collections::BTreeSet;
4445
4446 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4447 let events_rs = std::fs::read_to_string(manifest_dir.join("src/events.rs")).unwrap();
4448 let types_ts_path = manifest_dir.join("../../bindings/react-native/src/types.ts");
4449 let types_ts = std::fs::read_to_string(&types_ts_path)
4450 .unwrap_or_else(|e| panic!("cannot read {}: {e}", types_ts_path.display()));
4451
4452 // Scanned from the source text rather than from a hand-kept list of
4453 // variants, because a hand-kept list is the thing that drifts.
4454 let body = events_rs
4455 .split_once("pub enum PresenceSource {")
4456 .expect("PresenceSource not found")
4457 .1
4458 .split_once(
4459 "
4460}",
4461 )
4462 .expect("PresenceSource body not terminated")
4463 .0;
4464 let rust_sources: BTreeSet<String> = body
4465 .lines()
4466 .map(str::trim)
4467 .filter(|l| !l.starts_with("//") && !l.starts_with("#["))
4468 .filter_map(|l| l.strip_suffix(','))
4469 .filter(|name| !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric()))
4470 .map(|name| serde_snake_case(name))
4471 .collect();
4472
4473 // Negative control: an empty scan would make both assertions below
4474 // vacuously true.
4475 assert!(
4476 rust_sources.len() >= 2,
4477 "PresenceSource scan looks broken: only {} found",
4478 rust_sources.len()
4479 );
4480
4481 // Pins the scan against serde itself, so a rename rule change is
4482 // caught here rather than in an app's switch statement.
4483 assert_eq!(
4484 serde_json::to_string(&PresenceSource::Internet).unwrap(),
4485 "\"internet\"",
4486 "serde's rendering of PresenceSource changed"
4487 );
4488
4489 let union = types_ts
4490 .split_once("export type PresenceSource =")
4491 .expect("PresenceSource union not found in types.ts")
4492 .1
4493 .split_once(';')
4494 .expect("PresenceSource union not terminated")
4495 .0;
4496 let ts_sources: BTreeSet<String> = union
4497 .split('|')
4498 .map(str::trim)
4499 .filter_map(|t| t.strip_prefix('\''))
4500 .filter_map(|t| t.strip_suffix('\''))
4501 .map(str::to_string)
4502 .collect();
4503
4504 let missing: Vec<_> = rust_sources.difference(&ts_sources).collect();
4505 assert!(
4506 missing.is_empty(),
4507 "PresenceSource variants missing from the union in \
4508 bindings/react-native/src/types.ts: {missing:?}"
4509 );
4510
4511 let stale: Vec<_> = ts_sources.difference(&rust_sources).collect();
4512 assert!(
4513 stale.is_empty(),
4514 "types.ts declares presence sources with no Rust variant: {stale:?}"
4515 );
4516 }
4517}