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