Skip to main content

offline_protocol/
config.rs

1//! Protocol configuration.
2
3use crate::constants::DEFAULT_INITIAL_TTL;
4use crate::protocol::mesh_relay::{MeshRelayConfig, RELAY_QUEUE_MAX_OVERDUE};
5use offline_protocol_reliability::{AckConfig, DeduplicatorConfig, RetryConfig};
6use offline_protocol_router::{DorsConfig, RelayConfig};
7use serde::{Deserialize, Serialize};
8use std::sync::Arc;
9
10/// Overflow policy for bounded pending encrypted message queues.
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum OverflowPolicy {
14    /// Evict the oldest queued message to make room for the new message.
15    #[default]
16    DropOldest,
17    /// Drop the newly received message when capacity is reached.
18    DropNewest,
19}
20
21/// Configuration for inbound encrypted messages received before session readiness.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PendingQueueConfig {
24    /// Maximum number of pending encrypted messages per peer.
25    pub max_pending_per_peer: usize,
26    /// Maximum number of pending encrypted messages across all peers.
27    pub max_pending_global: usize,
28    /// Maximum total payload bytes (content plus binary content) queued per
29    /// peer.
30    ///
31    /// Encrypted media chunks are queued whole while the sender's session is
32    /// not yet ready, so the count limits alone would admit up to
33    /// `max_pending_per_peer` × chunk-size bytes of ciphertext. The default
34    /// (4 MB) covers the largest legitimate pre-session backlog: 2 concurrent
35    /// transfers × 8 in-flight chunks × 256 KB internet chunks.
36    #[serde(default = "default_max_pending_bytes_per_peer")]
37    pub max_pending_bytes_per_peer: usize,
38    /// Maximum total payload bytes queued across all peers. The default
39    /// (32 MB) bounds worst-case queue memory on mobile devices regardless of
40    /// how many peers are mid-establishment.
41    #[serde(default = "default_max_pending_bytes_global")]
42    pub max_pending_bytes_global: usize,
43    /// Time-to-live for pending encrypted messages in milliseconds.
44    pub pending_ttl_ms: u64,
45    /// Overflow policy when queue limits are reached.
46    pub overflow_policy: OverflowPolicy,
47}
48
49/// Default TTL for the pending-decryption queue (24 hours, in ms).
50///
51/// Under the deferred-ACK model an undecryptable message is no longer ACKed on
52/// receipt, so this queue is the primary recovery window before the session
53/// confirms — a 2-minute window was too short for a peer whose Welcome is slow
54/// to arrive/adopt, and 30 minutes was too short once the queue became
55/// durable: with a relay that pushes ciphertext without store-and-forward
56/// there is no second copy, so a frame that arrives while the recipient's
57/// session is still being set up has to outlive the handshake, which for a
58/// phone that is opened once a day means a day. Memory stays bounded by the
59/// per-peer/global byte caps plus the `DropOldest` overflow policy; a longer
60/// TTL only lets entries linger within those caps, it does not raise the
61/// ceiling. The TTL is measured on an `Instant` and so restarts with the
62/// process; the persisted copy is bounded separately by
63/// `PENDING_DECRYPT_PERSISTED_MAX_AGE_MS` (7 days).
64///
65/// The UniFFI `PendingQueueConfig` default mirrors this value — reference this
66/// constant there rather than re-hardcoding it.
67pub const DEFAULT_PENDING_TTL_MS: u64 = 86_400_000;
68
69fn default_max_pending_bytes_per_peer() -> usize {
70    4 * 1024 * 1024
71}
72
73fn default_max_pending_bytes_global() -> usize {
74    32 * 1024 * 1024
75}
76
77impl Default for PendingQueueConfig {
78    fn default() -> Self {
79        Self {
80            max_pending_per_peer: 64,
81            max_pending_global: 4096,
82            max_pending_bytes_per_peer: default_max_pending_bytes_per_peer(),
83            max_pending_bytes_global: default_max_pending_bytes_global(),
84            // 24 hours; see DEFAULT_PENDING_TTL_MS for the deferred-ACK
85            // rationale and the FFI-mirror contract.
86            pending_ttl_ms: DEFAULT_PENDING_TTL_MS,
87            overflow_policy: OverflowPolicy::DropOldest,
88        }
89    }
90}
91
92/// Encryption configuration for automatic MLS handling.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct EncryptionConfig {
95    /// Whether automatic encryption is enabled.
96    /// When enabled, messages are automatically encrypted/decrypted using MLS.
97    pub enabled: bool,
98
99    /// Auto-exchange key packages on peer discovery.
100    /// When enabled, key packages are automatically sent when neighbors are discovered.
101    pub auto_key_exchange: bool,
102
103    /// Store pending messages when no session exists.
104    /// Messages will be sent automatically after the session is established.
105    pub store_pending: bool,
106
107    /// Require encryption for messages (default: `true`).
108    ///
109    /// When enabled, sends fail closed if encryption cannot be applied:
110    /// with MLS uninitialized every send returns [`crate::Error::EncryptFailed`],
111    /// and with no confirmed session messages are queued (when
112    /// `store_pending` is set) or fail with
113    /// [`crate::Error::SessionNotReady`] instead of ever leaving as
114    /// plaintext. Inbound plaintext content — text messages and legacy
115    /// media alike — is rejected without being surfaced to the app
116    /// (plaintext carries no sender authentication, so anyone could
117    /// inject it under a contact's name); each rejection emits a
118    /// [`crate::events::SecurityWarningCode::PlaintextReceiveRejected`]
119    /// warning, once per peer. Even under the opt-out, inbound plaintext
120    /// from a peer *known to run MLS* is rejected as a downgrade/forgery
121    /// attempt — see `OfflineProtocol::encryption_capable_peers`. A
122    /// confirmed session is not required, and deliberately so: an honest
123    /// peer queues rather than downgrading while a session is pending, so
124    /// cleartext from a peer we know speaks MLS is never legitimate.
125    /// Peers that have shown no MLS signal stay readable, which is what
126    /// makes this opt-out usable for legacy interop.
127    ///
128    /// Disable only for deployments that deliberately send cleartext
129    /// (e.g. open broadcast/mesh bootstrap without provisioned MLS
130    /// storage) — set this to `false` explicitly, or use
131    /// [`EncryptionConfig::disabled`] to turn encryption off entirely.
132    /// Every plaintext send then emits a
133    /// [`crate::events::SecurityWarningCode::PlaintextSend`] warning
134    /// (once per peer). Internal control messages (key exchange, ACKs,
135    /// service discovery) are exempt and unaffected.
136    pub require_encryption: bool,
137
138    /// Bounds and eviction policy for pending inbound encrypted messages.
139    pub pending_queue: PendingQueueConfig,
140
141    /// Whether to negotiate and emit the compact MLS envelope for encrypted
142    /// messages (base64 of the binary `EncryptedMessage` form instead of JSON
143    /// with an integer-array ciphertext — roughly 2.7x smaller).
144    ///
145    /// Defaults to `true`. It only takes effect toward recipients that
146    /// advertise support in their key package (`env_versions`), so a mixed
147    /// fleet automatically stays on the JSON envelope. Parsing of inbound
148    /// compact envelopes is always on, independent of this flag; this gates
149    /// only advertising and emitting. The end-to-end sibling of
150    /// [`TransportConfig::binary_wire_enabled`], with an independent kill
151    /// switch because the two degrade separately.
152    pub compact_envelope_enabled: bool,
153
154    /// Whether to negotiate and seal the rich `__RICH_V1__` payload for
155    /// encrypted messages: quoted-reply context, rich media metadata, and
156    /// forward attribution carried *inside* the MLS ciphertext instead of on
157    /// the relay-visible outer message.
158    ///
159    /// Defaults to `true`. It only takes effect toward recipients that
160    /// advertise support in their key package (`rich_versions`); toward
161    /// everyone else the rich extras are silently dropped — never sent
162    /// cleartext. Parsing of inbound sealed payloads is always on,
163    /// independent of this flag; this gates only advertising and sealing.
164    /// Independent kill switch from
165    /// [`EncryptionConfig::compact_envelope_enabled`] — the two degrade
166    /// separately.
167    pub rich_payload_enabled: bool,
168
169    /// Whether to recover an inbound encrypted message that failed to decrypt,
170    /// rather than silently dropping it while telling the sender it was
171    /// delivered.
172    ///
173    /// Defaults to `true`. When enabled, an inbound encrypted DM (or media
174    /// chunk) that fails to decrypt is **not delivery-ACKed** and its id is not
175    /// left dedup-marked, so the sender keeps retrying instead of marking it
176    /// delivered — and each resend of a DM is re-sealed against the peer's
177    /// current session, so a resend can actually land. A message that stays
178    /// undeliverable settles as an honest `MessageFailed` once the sender's
179    /// retry budget lapses. When disabled, every decrypt failure falls back to
180    /// the legacy drop-and-ACK behavior.
181    ///
182    /// **Only an epoch mismatch additionally triggers a re-key.** A decrypt
183    /// failure *specifically* because the two sides disagree on the MLS epoch
184    /// (e.g. after a fork) also starts a rate-limited `session_reset`
185    /// key-package exchange to rebuild the channel. Failures that are *not* an
186    /// epoch mismatch — an AEAD / authentication failure, a discarded past
187    /// ratchet generation, a malformed frame — withhold the ACK but never
188    /// re-key: widening the re-key trigger to cover them would turn every
189    /// malformed frame into a teardown, which is an unbounded churn vector.
190    /// That separation is why the two classifications stay distinct.
191    ///
192    /// One consequence worth planning for: `MessageDecryptionFailed` is
193    /// **advisory, not terminal**, and fires once per failed *attempt* rather
194    /// than once per message. Treat it as "this attempt did not decrypt", and
195    /// read `MessageFailed` (or `FileReceiveFailed` for media) as the terminal
196    /// signal.
197    ///
198    /// **The re-key trigger is unauthenticated, by construction — do not read
199    /// this switch as "only genuine peers can cause a re-key".** An MLS epoch is
200    /// checked during *framing* validation, which happens before any AEAD,
201    /// sender-data, or signature check, and a 1:1 session slot id is derivable
202    /// from two public user ids. A hand-built frame carrying a wrong epoch
203    /// therefore reaches the recoverable classification with its sender still
204    /// entirely unverified: any party who can inject a frame can drive a re-key,
205    /// with no key material, no captured ciphertext and no session
206    /// (`test_forged_frame_reaches_session_desync_without_any_key_material`
207    /// builds one from scratch). This is inherent to MLS framing, not a defect,
208    /// and cannot be authenticated away — the credential a sender check would
209    /// compare against only exists once decrypt *succeeds*.
210    ///
211    /// What makes that safe is that acting on the trigger is **harmless**, not
212    /// that it is trusted: the re-key is confined to the claimed sender's own
213    /// session slot (so one derivable id cannot be aimed at other peers), is
214    /// rate-limited to one per peer per 30 s, discards no queued message, and
215    /// emits a `SESSION_REKEY_TRIGGERED` security warning so a sustained rate —
216    /// injection rather than a real fork — is visible to the app. The residual
217    /// is bounded re-key churn on a single pair: delivery delayed, never lost.
218    /// `OfflineProtocol::schedule_session_rekey` carries the full analysis.
219    pub crypto_recovery_enabled: bool,
220}
221
222impl Default for EncryptionConfig {
223    fn default() -> Self {
224        Self {
225            enabled: true,
226            auto_key_exchange: true,
227            store_pending: true,
228            // Fail closed by default (SEC-M3): a stock-config node must never
229            // silently degrade to plaintext because MLS was left uninitialized.
230            require_encryption: true,
231            pending_queue: PendingQueueConfig::default(),
232            compact_envelope_enabled: true,
233            rich_payload_enabled: true,
234            crypto_recovery_enabled: true,
235        }
236    }
237}
238
239impl EncryptionConfig {
240    /// Creates a new encryption config with encryption disabled.
241    pub fn disabled() -> Self {
242        Self {
243            enabled: false,
244            auto_key_exchange: false,
245            store_pending: false,
246            require_encryption: false,
247            pending_queue: PendingQueueConfig::default(),
248            compact_envelope_enabled: false,
249            rich_payload_enabled: false,
250            crypto_recovery_enabled: false,
251        }
252    }
253}
254
255/// Security configuration for transport and control-message hardening.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct SecurityConfig {
258    /// When `true`, control messages with no transport-level peer identity
259    /// (`transport_peer_id` is `None`) are rejected. When `false` (default),
260    /// missing transport identity emits a `SecurityWarning` but allows the
261    /// message through (best-effort / fail-open).
262    ///
263    /// # Why the default (`false`) is not the weak setting it once was
264    ///
265    /// This flag used to carry real weight, because a forged `sender` was
266    /// otherwise cheap: identities were app-chosen strings and the only thing
267    /// standing behind one was a trust-on-first-use pin, so first contact with
268    /// any peer was open to a man-in-the-middle and transport identity was the
269    /// only independent corroboration available.
270    ///
271    /// Under self-certifying addressing it is not. Every security-gated control
272    /// frame must now carry an Ed25519 signature — unconditionally, no longer
273    /// gated on this flag — and the signing key must derive to the address in
274    /// `sender`. A forged `sender` therefore requires the private key behind
275    /// that address, on first contact as much as on the thousandth. Transport
276    /// identity adds no authenticity on top of that; it only cross-checks a
277    /// claim the signature already proved.
278    ///
279    /// The strict match on frames claiming direct origin (`hop_count == 0`) runs
280    /// regardless of this flag, as it always has, and is unaffected.
281    ///
282    /// # What enabling it still does, and what it costs
283    ///
284    /// Exactly one thing: control frames arriving with **no** transport identity
285    /// at all are rejected instead of falling through to the signature gate.
286    ///
287    /// That is a real cost with no matching benefit on most deployments. Nostr
288    /// frames carry no transport identity by construction — the Nostr pubkey is
289    /// an install signing key, not the protocol id, so attaching it would fail
290    /// the strict match (see `nostr_message_received_inner`) — and relay frames
291    /// that arrive without a named sender are in the same position. Turning this
292    /// on rejects **every** control message on those paths, including the
293    /// key-package exchange that cold contact depends on. Mesh-forwarded frames
294    /// re-created by intermediate nodes legitimately lack identity too.
295    ///
296    /// Set to `true` only on a deployment that runs neither Nostr nor
297    /// sender-less relay delivery, and wants frames without transport identity
298    /// refused as a matter of policy rather than of authentication.
299    pub require_transport_identity: bool,
300
301    /// Whether a control frame is refused for being stale, and whether a peer
302    /// that has proved it signs the freshness-bound payload is held to it.
303    ///
304    /// Defaults to `true`. With it on, a control frame signed under
305    /// `offline-ctrl-v2` is checked against
306    /// [`CTRL_FRESHNESS_PAST_MS`](offline_protocol_sealed::CTRL_FRESHNESS_PAST_MS)
307    /// and its future counterpart, a peer that once verifiably signed v2 may
308    /// not fall back to v1, and a `session_reset` is honoured only on a frame
309    /// whose freshness was actually checked. With it off, this node behaves
310    /// exactly as it did before any of that existed: signatures are still
311    /// verified, and no frame is refused for its age.
312    ///
313    /// # What it is for
314    ///
315    /// The timestamp is read against *this device's* clock, so the failure
316    /// this switch exists for is a clock, not a peer. A device that comes up
317    /// with an unset clock reads every honest peer's frame as being from
318    /// decades in the future and refuses all of them, which takes out the
319    /// whole control plane — key package exchange included — for as long as
320    /// the clock is wrong. That is a self-inflicted outage a shipped app
321    /// cannot wait for a new binary to fix, which is why the lever exists and
322    /// why it reaches the bindings rather than staying internal.
323    ///
324    /// # What turning it off costs
325    ///
326    /// A captured control frame verifies forever again, and a recorded
327    /// `session_reset` is once more a repeatable way to tear down a live
328    /// session (issue 403). It is a diagnosis and recovery tool, not a
329    /// configuration a deployment should settle on.
330    pub control_freshness_enforced: bool,
331}
332
333impl Default for SecurityConfig {
334    fn default() -> Self {
335        Self {
336            // Fail-open, and safe to: see the field's own documentation for
337            // why transport identity adds no authenticity over a signature
338            // that must derive to the sender's address.
339            require_transport_identity: false,
340            // Fail-closed, like `require_encryption`: a freshness check that
341            // has to be switched on is one that is off on every install that
342            // never heard about it.
343            control_freshness_enforced: true,
344        }
345    }
346}
347
348/// Transport-specific configuration.
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct TransportConfig {
351    /// Whether BLE transport is enabled.
352    pub ble_enabled: bool,
353
354    /// Whether Wi-Fi Direct transport is enabled.
355    pub wifi_direct_enabled: bool,
356
357    /// Whether Internet transport is enabled.
358    pub internet_enabled: bool,
359
360    /// Whether Reticulum mesh transport is enabled.
361    /// Defaults to `false` because Reticulum requires external infrastructure
362    /// (a running Reticulum daemon or RNode hardware).
363    pub reticulum_enabled: bool,
364
365    /// Whether Nostr relay transport is enabled.
366    /// Defaults to `false` because Nostr requires relay configuration
367    /// and a secp256k1 keypair.
368    pub nostr_enabled: bool,
369
370    /// Whether outgoing Nostr frames are sealed into NIP-59 gift wraps.
371    ///
372    /// Defaults to `true`. With it off, the transport publishes the legacy
373    /// kind-4 event whose content is the whole protocol envelope in cleartext —
374    /// both usernames, the app id, the metadata map, the content type and a
375    /// millisecond timestamp, readable by every relay, permanently. Turn it off
376    /// only to interoperate with a relay that rejects kind 1059 outright.
377    ///
378    /// Send-side only, and never negotiated: inbound gift wraps are always
379    /// unsealed and inbound legacy frames always parsed, so whatever this is
380    /// set to, anything a peer sends us stays readable.
381    ///
382    /// It does still affect *outbound* reachability, in the one direction the
383    /// receive path cannot cover. A peer on a build that predates sealing
384    /// subscribes with `kinds: [4]` only, so it is never handed our kind-1059
385    /// events and has no NIP-44 layer to open one with — leaving it on makes
386    /// such a peer unreachable over Nostr (visibly: no ACK returns, so the
387    /// send fails through the retry ladder rather than vanishing). That is the
388    /// second reason to turn this off, alongside a relay that rejects
389    /// kind 1059.
390    pub nostr_sealing_enabled: bool,
391
392    /// Whether this install publishes MLS key packages to the relays and
393    /// resolves peers' published packages.
394    ///
395    /// Defaults to `true`, and takes effect only when `nostr_enabled` is on —
396    /// which is itself off by default, so nothing is published unless an app
397    /// deliberately turns Nostr on.
398    ///
399    /// Buys **cold first contact**: a peer known only by username becomes
400    /// reachable over Nostr with no prior key-package exchange over some other
401    /// transport, which is otherwise impossible. It also bounds the sealing
402    /// downgrade that follows a peer-key eviction, since a forgotten key is
403    /// re-resolved on the next send instead of waiting for a fresh exchange.
404    ///
405    /// **What it costs is worth stating plainly.** Publication is the first
406    /// thing this SDK emits to a relay unprompted: a small set of addressable
407    /// records sits at this install's routing tag and is refreshed as slots are
408    /// consumed, whether or not any message is ever sent. Their *content* is
409    /// sealed — a scraper filtering on the kind alone reads nothing, which
410    /// matters because an MLS key package carries its owner's username in the
411    /// leaf credential and cannot be stripped of it — but the existence of a
412    /// record at a given tag, and the timing of its refreshes, are visible to
413    /// every relay published to. Turn this off to keep the transport silent
414    /// until it has traffic, at the price of cold contact.
415    pub nostr_cold_contact_enabled: bool,
416
417    /// Whether this install publishes a username discovery record and can
418    /// resolve usernames to the devices claiming them.
419    ///
420    /// Defaults to **`false`**, unlike `nostr_cold_contact_enabled`, and gates
421    /// publication and resolution together. It also *requires* cold contact:
422    /// a discovery record points at an address whose key packages are what a
423    /// resolver fetches next, so with cold contact off the claim resolves and
424    /// then dead-ends one hop later. The two are hard-coupled in the transport
425    /// rather than merely documented.
426    ///
427    /// Buys back **reach by username**, which the addressing migration removed:
428    /// a stranger who knows only a name can find the addresses claiming it and
429    /// open a session. The name is published as the app's configured `profile`,
430    /// normalized to NFC and lowercase.
431    ///
432    /// **Default-off is the deliberate choice, and the reason is disclosure.**
433    /// Publishing binds a human-readable name to an address in a public place.
434    /// That is materially more than the key-package record's "an install with
435    /// this tag exists": the mapping *is* the payload. The record is sealed to
436    /// a key derived from the username, so a relay scraping by kind alone reads
437    /// nothing, but anyone who guesses the name can compute the tag, confirm a
438    /// claim exists, and read it. That is the same class of exposure a public
439    /// user directory has, and it is a decision an app should make explicitly.
440    ///
441    /// **What the directory is not: authoritative.** Anyone may publish any
442    /// claim at any name. Every record is a claim that *some key* asserts a
443    /// name, and a resolution returns the whole set of claimants with their
444    /// verification status. An app that silently picks the first result has
445    /// converted a non-authoritative directory into an authoritative-looking
446    /// one; the user must confirm out of band. See
447    /// `docs/spec/username-discovery.md`.
448    pub nostr_username_discovery_enabled: bool,
449
450    /// Whether to negotiate and emit the compact binary wire codec.
451    ///
452    /// Defaults to `true`. It only takes effect between two peers that both
453    /// advertise support (via their signed key package), so a mixed fleet
454    /// automatically stays on JSON. Decoding of binary frames is always on,
455    /// independent of this flag; this gates only advertising and emitting.
456    pub binary_wire_enabled: bool,
457}
458
459impl Default for TransportConfig {
460    fn default() -> Self {
461        Self {
462            ble_enabled: true,
463            wifi_direct_enabled: true,
464            internet_enabled: true,
465            reticulum_enabled: false,
466            nostr_enabled: false,
467            nostr_sealing_enabled: true,
468            nostr_cold_contact_enabled: true,
469            nostr_username_discovery_enabled: false,
470            binary_wire_enabled: true,
471        }
472    }
473}
474
475/// Reliability configuration combining ACK, retry, and deduplication settings.
476#[derive(Debug, Clone, Default)]
477pub struct ReliabilityConfig {
478    /// ACK manager configuration.
479    pub ack: AckConfig,
480
481    /// Retry queue configuration.
482    pub retry: RetryConfig,
483
484    /// Deduplicator configuration.
485    pub dedup: DeduplicatorConfig,
486}
487
488/// Configuration for group messaging.
489#[derive(Debug, Clone, Serialize, Deserialize)]
490pub struct GroupConfig {
491    /// Maximum number of members allowed in a single group.
492    ///
493    /// Per-member fan-out is O(N) when using mesh transports.
494    /// This cap prevents unbounded groups from overwhelming the network.
495    pub max_group_members: usize,
496
497    /// Whether to attempt relay server registration for groups.
498    ///
499    /// When `true` (default), groups are registered with the relay server
500    /// for optimized fan-out when Internet transport is available.
501    /// When `false`, groups always use per-member fan-out regardless of
502    /// transport.
503    ///
504    /// This gates *registration* only — the relay group registry is what
505    /// invite links resolve against, so it stays on independently of
506    /// [`Self::relay_broadcast_enabled`].
507    pub relay_enabled: bool,
508
509    /// Whether a relay-synced group may send via a single O(1) relay
510    /// broadcast instead of per-member fan-out.
511    ///
512    /// **Default `true`, and additionally gated on the relay's advertised
513    /// capabilities.** The flag alone does not select the broadcast: the
514    /// path is taken only when the connected relay also advertised the
515    /// `group_delivery_v3` capability in its `Authenticated` answer (v3 =
516    /// the v2 settled-report contract plus an address-aware group path —
517    /// members named in the identifier space the roster was registered in,
518    /// so the report is comparable against the MLS roster). Against
519    /// such a relay the broadcast keeps a delivery contract — the relay
520    /// answers every broadcast with a *settled* per-recipient delivery
521    /// report (`GroupMessageSent`) naming who took the message over a
522    /// socket and who took a device push, and the SDK re-sends a per-member
523    /// copy through the ordinary outbox/ACK/park ladder to every MLS roster
524    /// member the report does not account for. A lost report re-sends the
525    /// broadcast under the same logical message id (receiver and push dedup
526    /// absorb the duplicate fan-out) a bounded number of times, then
527    /// downgrades the message to full per-member fan-out. Against an older
528    /// relay the capability gate fails and every send takes per-member
529    /// fan-out — the fire-and-forget broadcast of the v1 relay (no presence
530    /// check, no push fallback, no persistence, "sent" answered before
531    /// delivery was known) is never taken, regardless of this flag.
532    ///
533    /// The one gap on the broadcast path: the tracker awaiting the report
534    /// lives in memory only, so a process death inside the report window
535    /// (up to `RELAY_BROADCAST_REPORT_TIMEOUT_SECS` per attempt) loses the
536    /// backstop with it — members the relay could not reach get no
537    /// per-member re-send, and nothing retries on restart. It needs both a
538    /// missed member and a kill in that window, and is strictly smaller than
539    /// the pre-report broadcast's exposure, but it is a real difference from
540    /// per-member fan-out, whose outbox entries persist for 7 days.
541    ///
542    /// Set `false` to force per-member fan-out even against a v2 relay —
543    /// the right choice for an app that must not lose a group message to a
544    /// mid-flight process kill.
545    /// Per-member fan-out (`__GRP_MLS_MSG__` as ordinary `SendMessage`
546    /// frames) inherits the full DM ladder end to end — outbox + ACK +
547    /// retry, relay write-ack + successor retry + offline push carrying the
548    /// ciphertext, park-on-unreachable with presence-driven flush, and
549    /// receiver-side deferred-ACK-after-decrypt — at the cost of O(N)
550    /// uplink frames per send.
551    ///
552    /// O(N) fan-out does **not** risk tripping the relay's rate limiter at
553    /// any group size. The platform bridge meters every relay-bound frame
554    /// through a client-side token bucket (`RelayRateLimiter`: 28 capacity,
555    /// 9/s refill) deliberately tighter than the relay's (30 burst, 10/s),
556    /// and a frame that cannot take a token is *deferred to a later poll
557    /// tick*, never dropped — so client spend stays at `28 + 9t` against a
558    /// server budget of `30 + 10t` and the fan-out self-paces below it
559    /// regardless of member count. Core enqueues all N frames at once; the
560    /// bridge drains that queue.
561    ///
562    /// What large groups actually cost on the per-member path is drain
563    /// latency. Frame N reaches the wire at roughly `(N - 28) / 9` seconds.
564    /// Since the ACK timer starts at local *enqueue* (`handle_send_success`)
565    /// and not at wire-confirm — `on_transport_send_confirmed` advances only
566    /// the welcome lifecycle — past roughly 118 members the tail of a single
567    /// fan-out exceeds the 10s ACK timeout and is retransmitted by the
568    /// ladder before it was ever written. That is wasted duplicate frames,
569    /// absorbed by receiver and push dedup via the stable outbox id, not
570    /// lost messages. Presence checks, typing, and receipts draw on the same
571    /// bucket and lower the threshold. This is the strongest reason to leave
572    /// the broadcast enabled for large groups.
573    pub relay_broadcast_enabled: bool,
574
575    /// Whether an incoming MLS membership commit is refused when the local
576    /// admin overlay does not authorize its committer.
577    ///
578    /// **Off by default, and turning it on is a decision about partition
579    /// risk, not a hardening toggle.** Refusing a commit means declining the
580    /// MLS merge: our epoch stays behind every member who did merge, and MLS
581    /// has no way back — the application has to re-invite us. Enforcement is
582    /// therefore fork-free only if every member reaches the *same* verdict,
583    /// and the admin overlay is replicated best-effort (role changes ride
584    /// unreconciled mesh notifications; a joiner gets a point-in-time
585    /// snapshot). Two members whose role maps merely disagree will partition
586    /// each other with no attacker involved.
587    ///
588    /// The check fails open on every *absent* input — no metadata, no admin
589    /// role stored, an unreadable roster — so the common lag case stays
590    /// harmless. It cannot detect *divergent* views, which is the residual
591    /// risk that keeps this opt-in. Enable it only for a closed deployment
592    /// that controls role distribution.
593    ///
594    /// With it off (the default), unauthorized membership changes are still
595    /// applied and reported: `group_unauthorized_membership_change` fires and
596    /// the roster events carry `authorized: false`. That reporting is
597    /// unaffected by this flag; the flag only decides whether the commit is
598    /// *also* refused.
599    ///
600    /// Rejection is receiver-local. The sender's message is still ACKed, so
601    /// a committer gets no signal that anyone refused, and members with the
602    /// flag off will apply a commit that members with it on rejected — do not
603    /// enable it on part of a fleet.
604    #[serde(default)]
605    pub enforce_admin_commits: bool,
606}
607
608impl Default for GroupConfig {
609    fn default() -> Self {
610        Self {
611            max_group_members: 256,
612            relay_enabled: true,
613            relay_broadcast_enabled: true,
614            enforce_admin_commits: false,
615        }
616    }
617}
618
619/// Configuration for the replicated-document data layer.
620///
621/// [`Self::enabled`] governs the whole layer: with it off no documents are
622/// opened, nothing is persisted, and no capability is advertised to peers.
623#[derive(Clone, Serialize, Deserialize)]
624pub struct DataConfig {
625    /// Whether the data layer accepts work at all.
626    ///
627    /// **Defaults to `true`.** It defaulted to `false` while the layer could
628    /// store documents but not replicate them: advertising a capability with
629    /// no sync behind it invites peers to expect a sync that never comes.
630    /// Both halves land in the same release, so no application ever ran
631    /// against the old default, and the switch is on.
632    ///
633    /// What being on costs an application that ignores documents entirely:
634    /// nothing at rest and nothing on the wire. The layer allocates when a
635    /// store is opened, and a capability is advertised in a key package that
636    /// is sent anyway. What it buys is that an application that *does* open a
637    /// store gets replication without discovering a flag.
638    #[serde(default = "default_data_enabled")]
639    pub enabled: bool,
640
641    /// Where documents are stored, when the application wants them somewhere
642    /// other than the store protocol state already uses.
643    ///
644    /// `None` — the default — means documents live in the same backend as the
645    /// rest of protocol state, which is what makes the layer work with zero
646    /// storage setup: every binding already ships a default provider.
647    ///
648    /// Setting it points documents at SQLite, RocksDB, files, a corporate
649    /// store, or anything else implementing the same trait, while protocol
650    /// secrets stay exactly where they are. The two concerns swap
651    /// independently and the swap is a runtime choice: no rebuild, no cargo
652    /// feature, and no change to any data API. Anything that makes this
653    /// harder than assigning this field is a design regression.
654    ///
655    /// Sealing sits above this seam. An adapter is handed sealed bytes and
656    /// never sees document content, so a custom backend cannot weaken the
657    /// at-rest posture even by accident.
658    ///
659    /// # Logout
660    ///
661    /// `wipePersistedState` clears the account directory of the *default*
662    /// provider. A custom backend is somewhere the bindings cannot know
663    /// about, so an application that sets this must call the data layer's
664    /// own wipe on logout, or documents outlive the account that made them.
665    #[serde(skip)]
666    pub storage: Option<Arc<dyn crate::protocol_state_storage::ProtocolStateStorage>>,
667}
668
669/// The default for [`DataConfig::enabled`], needed by name because `serde`
670/// cannot ask a field's type for anything other than its own default.
671fn default_data_enabled() -> bool {
672    true
673}
674
675impl Default for DataConfig {
676    fn default() -> Self {
677        Self {
678            enabled: default_data_enabled(),
679            storage: None,
680        }
681    }
682}
683
684impl std::fmt::Debug for DataConfig {
685    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
686        f.debug_struct("DataConfig")
687            .field("enabled", &self.enabled)
688            .field("storage", &self.storage.as_ref().map(|_| "<custom>"))
689            .finish()
690    }
691}
692
693/// Main configuration for the Offline Protocol.
694#[derive(Debug, Clone)]
695pub struct ProtocolConfig {
696    /// Application identifier (required).
697    pub app_id: String,
698
699    /// Local profile selector (required).
700    ///
701    /// Selects which stored identity this instance runs as — one namespace per
702    /// `(app_id, profile)` pair — so an app hosting several accounts gives each
703    /// its own value, and an app hosting one can pass a constant like
704    /// `"default"`.
705    ///
706    /// It is **not** the device's identity. The wire identity is the
707    /// self-certifying address derived from the identity key held in this
708    /// profile's storage: it is minted on first start, reported by
709    /// [`crate::OfflineProtocol::local_address`], and it is what gets stamped
710    /// as `Message.sender`, what peers see as `NeighborDiscovered.peer_id`,
711    /// and what others use as `recipient`. An app cannot choose it, because
712    /// choosing it would mean claiming a key one does not hold.
713    ///
714    /// Under any configuration that reaches the wire the profile stays local.
715    /// The one exception is the explicit
716    /// [`EncryptionConfig::require_encryption`]` = false` opt-out, which lets a
717    /// plaintext send go out with MLS never initialized — no address has been
718    /// derived at that point, so the frame carries the profile as its sender
719    /// and the send emits
720    /// [`crate::events::SecurityWarningCode::PlaintextSend`]. The default
721    /// (`require_encryption = true`) fails such a send closed instead. Treat
722    /// the profile as private if that opt-out is in use.
723    pub profile: String,
724
725    /// Transport configuration.
726    pub transport: TransportConfig,
727
728    /// DORS (Dynamic Offline Relay Switch) configuration.
729    pub dors: DorsConfig,
730
731    /// Relay management configuration: whether this device carries traffic
732    /// for other people at all, and the battery floors under which it stops.
733    pub relay: RelayConfig,
734
735    /// How this device carries traffic for other people: how far frames
736    /// travel, how many neighbors each is handed to, and the ceilings that
737    /// keep forwarding from crowding the radio out.
738    ///
739    /// Whether to forward is [`Self::relay`]'s decision; this is the shape of
740    /// it once the answer is yes.
741    pub mesh_relay: MeshRelayConfig,
742
743    /// Reliability layer configuration.
744    pub reliability: ReliabilityConfig,
745
746    /// Encryption configuration for automatic MLS handling.
747    pub encryption: EncryptionConfig,
748
749    /// Initial TTL (Time-To-Live) for messages.
750    pub initial_ttl: u8,
751
752    /// Mesh group messaging configuration.
753    pub group: GroupConfig,
754
755    /// Security configuration for transport and control-message hardening.
756    pub security: SecurityConfig,
757
758    /// Replicated-document data layer configuration.
759    pub data: DataConfig,
760}
761
762impl ProtocolConfig {
763    /// Creates a new protocol configuration.
764    ///
765    /// # Arguments
766    ///
767    /// * `app_id` - Application identifier
768    /// * `profile` - Local profile selector (storage namespace, never on the wire)
769    pub fn new(app_id: impl Into<String>, profile: impl Into<String>) -> Self {
770        Self {
771            app_id: app_id.into(),
772            profile: profile.into(),
773            transport: TransportConfig::default(),
774            dors: DorsConfig::default(),
775            relay: RelayConfig::default(),
776            mesh_relay: MeshRelayConfig::default(),
777            reliability: ReliabilityConfig::default(),
778            encryption: EncryptionConfig::default(),
779            initial_ttl: DEFAULT_INITIAL_TTL,
780            group: GroupConfig::default(),
781            security: SecurityConfig::default(),
782            data: DataConfig::default(),
783        }
784    }
785
786    /// Creates a builder for more granular configuration.
787    pub fn builder(app_id: impl Into<String>, profile: impl Into<String>) -> ProtocolConfigBuilder {
788        ProtocolConfigBuilder::new(app_id, profile)
789    }
790
791    /// Validates the configuration.
792    ///
793    /// # Returns
794    ///
795    /// Returns `Ok(())` if valid, `Err` with a description of the problem if invalid.
796    pub fn validate(&self) -> crate::Result<()> {
797        // Both become storage keys — `app_id` and `profile` are the two inputs
798        // to the account namespace — so hold them to the same length cap and
799        // charset ban as every other identifier rather than only rejecting the
800        // empty string. Going through the core constructors keeps one policy
801        // instead of a second copy that can drift. Catching this here turns a
802        // later failure deep inside a storage backend into one clear
803        // configuration error.
804        offline_protocol_core::AppId::new(self.app_id.as_str())
805            .map_err(|e| crate::Error::InvalidConfiguration(e.to_string()))?;
806
807        offline_protocol_core::UserId::new(self.profile.as_str())
808            .map_err(|e| crate::Error::InvalidConfiguration(e.to_string()))?;
809
810        if self.initial_ttl == 0 {
811            return Err(crate::Error::InvalidConfiguration(
812                "initial_ttl must be greater than 0".to_string(),
813            ));
814        }
815
816        if !self.transport.ble_enabled
817            && !self.transport.wifi_direct_enabled
818            && !self.transport.internet_enabled
819            && !self.transport.reticulum_enabled
820            && !self.transport.nostr_enabled
821        {
822            return Err(crate::Error::InvalidConfiguration(
823                "At least one transport must be enabled".to_string(),
824            ));
825        }
826
827        if self.reliability.ack.default_timeout_ms == 0 {
828            return Err(crate::Error::InvalidConfiguration(
829                "ack.default_timeout_ms must be greater than 0".to_string(),
830            ));
831        }
832
833        if self.reliability.ack.max_pending_acks == 0 {
834            return Err(crate::Error::InvalidConfiguration(
835                "ack.max_pending_acks must be greater than 0".to_string(),
836            ));
837        }
838
839        if self.reliability.retry.initial_delay_ms == 0 {
840            return Err(crate::Error::InvalidConfiguration(
841                "retry.initial_delay_ms must be greater than 0".to_string(),
842            ));
843        }
844
845        if self.reliability.retry.max_delay_ms == 0 {
846            return Err(crate::Error::InvalidConfiguration(
847                "retry.max_delay_ms must be greater than 0".to_string(),
848            ));
849        }
850
851        if self.reliability.retry.initial_delay_ms > self.reliability.retry.max_delay_ms {
852            return Err(crate::Error::InvalidConfiguration(
853                "retry.initial_delay_ms must be <= retry.max_delay_ms".to_string(),
854            ));
855        }
856
857        if self.reliability.retry.outbox_max_lifetime_ms == 0
858            || self.reliability.retry.outbox_max_lifetime_ms > i64::MAX as u64
859        {
860            return Err(crate::Error::InvalidConfiguration(
861                "retry.outbox_max_lifetime_ms must be in 1..=i64::MAX".to_string(),
862            ));
863        }
864
865        if self.reliability.retry.pending_message_max_lifetime_ms == 0 {
866            return Err(crate::Error::InvalidConfiguration(
867                "retry.pending_message_max_lifetime_ms must be greater than 0".to_string(),
868            ));
869        }
870
871        if self.reliability.retry.pending_message_max_lifetime_ms > i64::MAX as u64 {
872            return Err(crate::Error::InvalidConfiguration(
873                "retry.pending_message_max_lifetime_ms must be <= i64::MAX".to_string(),
874            ));
875        }
876
877        if !self.reliability.retry.backoff_multiplier.is_finite()
878            || self.reliability.retry.backoff_multiplier < 1.0
879        {
880            return Err(crate::Error::InvalidConfiguration(
881                "retry.backoff_multiplier must be finite and >= 1.0".to_string(),
882            ));
883        }
884
885        // Checked outside the Bloom guard below, and that placement is the
886        // whole point: these two are the *only* dedup fields the UniFFI
887        // `DedupConfig` carries, so every constraint hidden behind
888        // `use_bloom_filter` is unreachable from a binding caller. A zero here
889        // is not a cosmetic problem — `Deduplicator` fails safe on the Bloom
890        // parameters, but nothing fails safe on these. At
891        // `max_tracked_messages == 0` the HashMap branch of `mark_seen` evicts
892        // on every insert, so the map holds one id and duplicate suppression is
893        // effectively off; `retention_time_secs == 0` expires every entry
894        // immediately for the same net result.
895        //
896        // What this rejects is the *degenerate* value, and that is the whole of
897        // the claim: a floor of 1 is not a floor on how well duplicates are
898        // suppressed. `max_tracked_messages == 1` behaves indistinguishably
899        // from `0`, and a two-second retention on a link whose retry backoff is
900        // longer than that suppresses nothing either. Choosing a window wide
901        // enough for the deployment is the application's call — the SDK refuses
902        // only the values that cannot be a choice, because nothing downstream
903        // spells them as a configuration error and the failure they produce
904        // (silently re-delivered messages) does not look like a config bug.
905        // Raising either floor to a policy minimum is a wider behavioural break
906        // and would need its own decision.
907        if self.reliability.dedup.max_tracked_messages == 0 {
908            return Err(crate::Error::InvalidConfiguration(
909                "reliability.dedup.max_tracked_messages must be greater than 0".to_string(),
910            ));
911        }
912
913        if self.reliability.dedup.retention_time_secs == 0 {
914            return Err(crate::Error::InvalidConfiguration(
915                "reliability.dedup.retention_time_secs must be greater than 0".to_string(),
916            ));
917        }
918
919        if self.reliability.dedup.use_bloom_filter {
920            if self.reliability.dedup.bloom_filter_bits == 0 {
921                return Err(crate::Error::InvalidConfiguration(
922                    "reliability.dedup.bloom_filter_bits must be greater than 0".to_string(),
923                ));
924            }
925
926            if self.reliability.dedup.bloom_hash_count == 0 {
927                return Err(crate::Error::InvalidConfiguration(
928                    "reliability.dedup.bloom_hash_count must be greater than 0".to_string(),
929                ));
930            }
931
932            if self.reliability.dedup.bloom_filter_count == 0 {
933                return Err(crate::Error::InvalidConfiguration(
934                    "reliability.dedup.bloom_filter_count must be greater than 0".to_string(),
935                ));
936            }
937
938            if self.reliability.dedup.bloom_rotation_secs == 0 {
939                return Err(crate::Error::InvalidConfiguration(
940                    "reliability.dedup.bloom_rotation_secs must be greater than 0".to_string(),
941                ));
942            }
943        }
944
945        if self.encryption.require_encryption && !self.encryption.enabled {
946            return Err(crate::Error::InvalidConfiguration(
947                "encryption.require_encryption requires encryption.enabled=true".to_string(),
948            ));
949        }
950
951        if self.encryption.pending_queue.max_pending_per_peer == 0 {
952            return Err(crate::Error::InvalidConfiguration(
953                "encryption.pending_queue.max_pending_per_peer must be greater than 0".to_string(),
954            ));
955        }
956
957        if self.encryption.pending_queue.max_pending_global == 0 {
958            return Err(crate::Error::InvalidConfiguration(
959                "encryption.pending_queue.max_pending_global must be greater than 0".to_string(),
960            ));
961        }
962
963        if self.encryption.pending_queue.max_pending_global
964            < self.encryption.pending_queue.max_pending_per_peer
965        {
966            return Err(crate::Error::InvalidConfiguration(
967                "encryption.pending_queue.max_pending_global must be >= max_pending_per_peer"
968                    .to_string(),
969            ));
970        }
971
972        if self.encryption.pending_queue.pending_ttl_ms == 0 {
973            return Err(crate::Error::InvalidConfiguration(
974                "encryption.pending_queue.pending_ttl_ms must be greater than 0".to_string(),
975            ));
976        }
977
978        if self.group.max_group_members == 0 {
979            return Err(crate::Error::InvalidConfiguration(
980                "group.max_group_members must be greater than 0".to_string(),
981            ));
982        }
983
984        // A fan-out of zero is not a cheaper forward, it is a silent drop: the
985        // frame has already been admitted and recorded as seen, so no copy goes
986        // out and this node's suppression entry stops it arriving again by
987        // another path. Same argument as `bias_min_scale` below — declining to
988        // carry other people's traffic is `relay.allow_relay`'s job, and it is
989        // visible. The forwarding paths clamp with `.max(1)`; rejecting zero
990        // here keeps that clamp an unreachable defensive floor instead of a
991        // silent rewrite of what zero means.
992        if self.mesh_relay.fanout == 0 {
993            return Err(crate::Error::InvalidConfiguration(
994                "mesh_relay.fanout must be greater than 0".to_string(),
995            ));
996        }
997
998        // The same argument as fan-out, for every other dial that can reach
999        // zero. Each of these reads like a conservative setting and is in fact
1000        // an off switch that reports nothing: the failure is total, silent, and
1001        // indistinguishable from a quiet neighborhood, which is what makes
1002        // rejecting it at construction worth more than any runtime clamp.
1003        //
1004        // A hop ceiling of zero clamps every arriving budget to nothing, so
1005        // `prepare_hop` refuses the frame before it is ever queued. Both
1006        // ceilings are checked: the dense one applies only in a crowded room,
1007        // so a zero there fails exactly where the mesh is most needed and
1008        // nowhere else — the hardest version of this bug to reproduce.
1009        if self.mesh_relay.max_ttl == 0 {
1010            return Err(crate::Error::InvalidConfiguration(
1011                "mesh_relay.max_ttl must be greater than 0".to_string(),
1012            ));
1013        }
1014
1015        if self.mesh_relay.dense_max_ttl == 0 {
1016            return Err(crate::Error::InvalidConfiguration(
1017                "mesh_relay.dense_max_ttl must be greater than 0".to_string(),
1018            ));
1019        }
1020
1021        // A queue that holds nothing refuses every admission: the length is
1022        // always at or over capacity, and eviction has no victim to find, so
1023        // each frame is rejected as queue-full. Nothing is forwarded and the
1024        // only trace is `refused_queue_full`.
1025        if self.mesh_relay.queue_capacity == 0 {
1026            return Err(crate::Error::InvalidConfiguration(
1027                "mesh_relay.queue_capacity must be greater than 0".to_string(),
1028            ));
1029        }
1030
1031        // The rate and burst dials feed token buckets that clamp their inputs
1032        // with `.max(0.0)`. That clamp makes a negative or NaN value safe in
1033        // the arithmetic sense and catastrophic in every other: the bucket
1034        // simply never yields a token, so forwarding stops for good with no
1035        // error and no counter. NaN matters on its own because `f32::max`
1036        // returns the *other* operand for it, so a NaN rate silently becomes
1037        // zero rather than propagating anywhere visible.
1038        //
1039        // Checked as a set: they are the same failure, and naming the field in
1040        // the message is what makes a rejected config actionable.
1041        for (name, value) in [
1042            ("rate_per_sec", self.mesh_relay.rate_per_sec),
1043            ("burst", self.mesh_relay.burst),
1044            ("peer_rate_per_sec", self.mesh_relay.peer_rate_per_sec),
1045            ("peer_burst", self.mesh_relay.peer_burst),
1046        ] {
1047            if !value.is_finite() || value <= 0.0 {
1048                return Err(crate::Error::InvalidConfiguration(format!(
1049                    "mesh_relay.{name} must be finite and greater than 0"
1050                )));
1051            }
1052        }
1053
1054        // Capability bias scales forwarding effort; it must never be able to
1055        // scale it to nothing. Zero would make a low-battery device stop
1056        // forwarding by the back door, which is the partition the bias design
1057        // exists to avoid — refusing to relay is `relay.allow_relay`'s job.
1058        if !self.mesh_relay.bias_min_scale.is_finite()
1059            || self.mesh_relay.bias_min_scale <= 0.0
1060            || self.mesh_relay.bias_min_scale > 1.0
1061        {
1062            return Err(crate::Error::InvalidConfiguration(
1063                "mesh_relay.bias_min_scale must be finite and in (0.0, 1.0]".to_string(),
1064            ));
1065        }
1066
1067        // An inverted jitter window is not a narrow window, it is two separate
1068        // failures. `jitter_for` raises the effective maximum to meet the
1069        // minimum, so the span collapses to a single millisecond and every
1070        // neighbor picks essentially the same delay — losing the spread that
1071        // stops a crowded room transmitting on top of itself, which is the
1072        // whole reason the delay exists. And because the check below reads
1073        // `jitter_max`, an oversized `jitter_min` would slip past the overdue
1074        // cut-off entirely: a ten-minute minimum with the default maximum
1075        // validates clean and abandons every forward.
1076        if self.mesh_relay.jitter_min > self.mesh_relay.jitter_max {
1077            return Err(crate::Error::InvalidConfiguration(format!(
1078                "mesh_relay.jitter_min ({}ms) must not exceed mesh_relay.jitter_max ({}ms)",
1079                self.mesh_relay.jitter_min.as_millis(),
1080                self.mesh_relay.jitter_max.as_millis(),
1081            )));
1082        }
1083
1084        // A forward is abandoned rather than sent once it sits
1085        // `RELAY_QUEUE_MAX_OVERDUE` past its due time, so the delay tunables
1086        // must not be able to reach that on their own. Checked at the density
1087        // the jitter span is configured for; the span also grows with neighbor
1088        // count, which is a runtime quantity this cannot see and a documented
1089        // ceiling of the mesh's venue-sized target rather than a config error.
1090        let worst_delay = self
1091            .mesh_relay
1092            .jitter_max
1093            .saturating_add(self.mesh_relay.bias_max_handicap);
1094        if worst_delay >= RELAY_QUEUE_MAX_OVERDUE {
1095            return Err(crate::Error::InvalidConfiguration(format!(
1096                "mesh_relay.jitter_max + mesh_relay.bias_max_handicap ({}ms) must stay under \
1097                 the {}ms overdue cut-off, or every biased forward is abandoned instead of sent",
1098                worst_delay.as_millis(),
1099                RELAY_QUEUE_MAX_OVERDUE.as_millis(),
1100            )));
1101        }
1102
1103        if self.mesh_relay.activity_window.is_zero() {
1104            return Err(crate::Error::InvalidConfiguration(
1105                "mesh_relay.activity_window must be greater than zero".to_string(),
1106            ));
1107        }
1108
1109        if self.mesh_relay.activity_min_forwards == 0 {
1110            return Err(crate::Error::InvalidConfiguration(
1111                "mesh_relay.activity_min_forwards must be greater than 0".to_string(),
1112            ));
1113        }
1114
1115        if self.mesh_relay.activity_idle_windows == 0 {
1116            return Err(crate::Error::InvalidConfiguration(
1117                "mesh_relay.activity_idle_windows must be greater than 0".to_string(),
1118            ));
1119        }
1120
1121        Ok(())
1122    }
1123}
1124
1125/// Builder for ProtocolConfig with a fluent API.
1126pub struct ProtocolConfigBuilder {
1127    config: ProtocolConfig,
1128}
1129
1130impl ProtocolConfigBuilder {
1131    /// Creates a new builder.
1132    pub fn new(app_id: impl Into<String>, profile: impl Into<String>) -> Self {
1133        Self {
1134            config: ProtocolConfig::new(app_id, profile),
1135        }
1136    }
1137
1138    /// Configures transports.
1139    pub fn transport(mut self, config: TransportConfig) -> Self {
1140        self.config.transport = config;
1141        self
1142    }
1143
1144    /// Enables or disables BLE transport.
1145    pub fn ble_enabled(mut self, enabled: bool) -> Self {
1146        self.config.transport.ble_enabled = enabled;
1147        self
1148    }
1149
1150    /// Enables or disables Wi-Fi Direct transport.
1151    pub fn wifi_direct_enabled(mut self, enabled: bool) -> Self {
1152        self.config.transport.wifi_direct_enabled = enabled;
1153        self
1154    }
1155
1156    /// Enables or disables Internet transport.
1157    pub fn internet_enabled(mut self, enabled: bool) -> Self {
1158        self.config.transport.internet_enabled = enabled;
1159        self
1160    }
1161
1162    /// Enables or disables Nostr relay transport.
1163    pub fn nostr_enabled(mut self, enabled: bool) -> Self {
1164        self.config.transport.nostr_enabled = enabled;
1165        self
1166    }
1167
1168    /// Enables or disables sealing of outgoing Nostr frames (default on).
1169    pub fn nostr_sealing_enabled(mut self, enabled: bool) -> Self {
1170        self.config.transport.nostr_sealing_enabled = enabled;
1171        self
1172    }
1173
1174    /// Enables username discovery publication and resolution
1175    /// (default **off**; see
1176    /// [`TransportConfig::nostr_username_discovery_enabled`]).
1177    pub fn nostr_username_discovery_enabled(mut self, enabled: bool) -> Self {
1178        self.config.transport.nostr_username_discovery_enabled = enabled;
1179        self
1180    }
1181
1182    /// Enables or disables Nostr key-package publication and peer resolution
1183    /// (default on; see [`TransportConfig::nostr_cold_contact_enabled`]).
1184    pub fn nostr_cold_contact_enabled(mut self, enabled: bool) -> Self {
1185        self.config.transport.nostr_cold_contact_enabled = enabled;
1186        self
1187    }
1188
1189    /// Configures DORS (Dynamic Offline Relay Switch).
1190    pub fn dors(mut self, config: DorsConfig) -> Self {
1191        self.config.dors = config;
1192        self
1193    }
1194
1195    /// Enables online-first mode (prefer Internet when available).
1196    pub fn online_first(mut self, enabled: bool) -> Self {
1197        self.config.dors.prefer_online = enabled;
1198        self
1199    }
1200
1201    /// Configures relay management.
1202    pub fn relay(mut self, config: RelayConfig) -> Self {
1203        self.config.relay = config;
1204        self
1205    }
1206
1207    /// Configures how traffic is carried for other people.
1208    pub fn mesh_relay(mut self, config: MeshRelayConfig) -> Self {
1209        self.config.mesh_relay = config;
1210        self
1211    }
1212
1213    /// Configures reliability layer.
1214    pub fn reliability(mut self, config: ReliabilityConfig) -> Self {
1215        self.config.reliability = config;
1216        self
1217    }
1218
1219    /// Sets the initial TTL for messages.
1220    pub fn initial_ttl(mut self, ttl: u8) -> Self {
1221        self.config.initial_ttl = ttl;
1222        self
1223    }
1224
1225    /// Configures encryption settings.
1226    pub fn encryption(mut self, config: EncryptionConfig) -> Self {
1227        self.config.encryption = config;
1228        self
1229    }
1230
1231    /// Enables or disables automatic encryption.
1232    pub fn encryption_enabled(mut self, enabled: bool) -> Self {
1233        self.config.encryption.enabled = enabled;
1234        self
1235    }
1236
1237    /// Enables or disables automatic key exchange on peer discovery.
1238    pub fn auto_key_exchange(mut self, enabled: bool) -> Self {
1239        self.config.encryption.auto_key_exchange = enabled;
1240        self
1241    }
1242
1243    /// Enables or disables storing pending messages for later encryption.
1244    pub fn store_pending_messages(mut self, enabled: bool) -> Self {
1245        self.config.encryption.store_pending = enabled;
1246        self
1247    }
1248
1249    /// Enables or disables strict encryption requirement.
1250    pub fn require_encryption(mut self, required: bool) -> Self {
1251        self.config.encryption.require_encryption = required;
1252        self
1253    }
1254
1255    /// Configures pending encrypted message queue bounds and overflow behavior.
1256    pub fn pending_queue(mut self, config: PendingQueueConfig) -> Self {
1257        self.config.encryption.pending_queue = config;
1258        self
1259    }
1260
1261    /// Configures group messaging settings.
1262    pub fn group(mut self, config: GroupConfig) -> Self {
1263        self.config.group = config;
1264        self
1265    }
1266
1267    /// Sets the maximum number of members allowed in a single group.
1268    pub fn max_group_members(mut self, max: usize) -> Self {
1269        self.config.group.max_group_members = max;
1270        self
1271    }
1272
1273    /// Sets whether groups should attempt relay server registration.
1274    pub fn group_relay_enabled(mut self, enabled: bool) -> Self {
1275        self.config.group.relay_enabled = enabled;
1276        self
1277    }
1278
1279    /// Sets whether relay-synced groups may send via a single relay broadcast
1280    /// instead of per-member fan-out. See
1281    /// [`GroupConfig::relay_broadcast_enabled`] — on by default, but only
1282    /// ever taken against a relay that advertised the `group_delivery_v3`
1283    /// capability, whose settled, address-space delivery report gives the
1284    /// broadcast a per-recipient delivery contract.
1285    pub fn group_relay_broadcast_enabled(mut self, enabled: bool) -> Self {
1286        self.config.group.relay_broadcast_enabled = enabled;
1287        self
1288    }
1289
1290    /// Sets whether unauthorized MLS membership commits are refused rather
1291    /// than applied-and-reported. See
1292    /// [`GroupConfig::enforce_admin_commits`] — off by default, and enabling
1293    /// it risks permanently forking members whose admin overlay disagrees.
1294    pub fn group_enforce_admin_commits(mut self, enabled: bool) -> Self {
1295        self.config.group.enforce_admin_commits = enabled;
1296        self
1297    }
1298
1299    /// Configures security settings.
1300    pub fn security(mut self, config: SecurityConfig) -> Self {
1301        self.config.security = config;
1302        self
1303    }
1304
1305    /// Sets whether transport identity is required for control messages.
1306    pub fn require_transport_identity(mut self, required: bool) -> Self {
1307        self.config.security.require_transport_identity = required;
1308        self
1309    }
1310
1311    /// Configures the replicated-document data layer.
1312    pub fn data(mut self, config: DataConfig) -> Self {
1313        self.config.data = config;
1314        self
1315    }
1316
1317    /// Sets whether the data layer accepts work. See [`DataConfig::enabled`].
1318    pub fn data_enabled(mut self, enabled: bool) -> Self {
1319        self.config.data.enabled = enabled;
1320        self
1321    }
1322
1323    /// Points documents at a storage backend of the application's choosing.
1324    ///
1325    /// The whole bring-your-own-backend path, in one call. See
1326    /// [`DataConfig::storage`], including what it means for logout.
1327    pub fn data_storage(
1328        mut self,
1329        storage: Arc<dyn crate::protocol_state_storage::ProtocolStateStorage>,
1330    ) -> Self {
1331        self.config.data.storage = Some(storage);
1332        self
1333    }
1334
1335    /// Builds and validates the configuration.
1336    ///
1337    /// # Returns
1338    ///
1339    /// Returns `Ok(ProtocolConfig)` if valid, `Err` otherwise.
1340    pub fn build(self) -> crate::Result<ProtocolConfig> {
1341        self.config.validate()?;
1342        Ok(self.config)
1343    }
1344}
1345
1346#[cfg(test)]
1347mod tests {
1348    use super::*;
1349
1350    #[test]
1351    fn test_config_creation() {
1352        let config = ProtocolConfig::new("test-app", "user123");
1353        assert_eq!(config.app_id, "test-app");
1354        assert_eq!(config.profile, "user123");
1355        assert_eq!(config.initial_ttl, 8);
1356    }
1357
1358    #[test]
1359    fn test_config_validation_success() {
1360        let config = ProtocolConfig::new("test-app", "user123");
1361        assert!(config.validate().is_ok());
1362    }
1363
1364    #[test]
1365    fn test_config_validation_empty_app_id() {
1366        let config = ProtocolConfig::new("", "user123");
1367        assert!(config.validate().is_err());
1368    }
1369
1370    #[test]
1371    fn test_config_validation_empty_user_id() {
1372        let config = ProtocolConfig::new("test-app", "");
1373        assert!(config.validate().is_err());
1374    }
1375
1376    #[test]
1377    fn config_validation_enforces_the_id_length_cap() {
1378        // An unbounded id becomes an unbounded storage key and an unbounded
1379        // wire field; the cap is the same one the core newtypes apply.
1380        let at_cap = "a".repeat(offline_protocol_core::MAX_ID_LEN);
1381        let over_cap = "a".repeat(offline_protocol_core::MAX_ID_LEN + 1);
1382
1383        assert!(ProtocolConfig::new("test-app", &at_cap).validate().is_ok());
1384        assert!(ProtocolConfig::new(&at_cap, "user123").validate().is_ok());
1385        assert!(ProtocolConfig::new("test-app", &over_cap)
1386            .validate()
1387            .is_err());
1388        assert!(ProtocolConfig::new(&over_cap, "user123")
1389            .validate()
1390            .is_err());
1391    }
1392
1393    #[test]
1394    fn config_validation_enforces_the_id_charset() {
1395        // Path separators and the KV namespace separator would let an id
1396        // escape or collide with another account's storage keys.
1397        for hostile in ["a/b", "a\\b", "a:b", ".", "..", "a\u{0}b", "a\nb"] {
1398            assert!(
1399                ProtocolConfig::new("test-app", hostile).validate().is_err(),
1400                "user_id {hostile:?} must be rejected"
1401            );
1402            assert!(
1403                ProtocolConfig::new(hostile, "user123").validate().is_err(),
1404                "app_id {hostile:?} must be rejected"
1405            );
1406        }
1407    }
1408
1409    #[test]
1410    fn config_validation_accepts_an_address_shaped_id() {
1411        // The self-certifying address format must pass the id policy: the
1412        // bech32m charset is plain lowercase alphanumerics.
1413        let address = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgvn";
1414        assert!(ProtocolConfig::new("test-app", address).validate().is_ok());
1415    }
1416
1417    #[test]
1418    fn test_config_validation_zero_ttl() {
1419        let mut config = ProtocolConfig::new("test-app", "user123");
1420        config.initial_ttl = 0;
1421        assert!(config.validate().is_err());
1422    }
1423
1424    #[test]
1425    fn test_config_validation_no_transports() {
1426        let mut config = ProtocolConfig::new("test-app", "user123");
1427        config.transport.ble_enabled = false;
1428        config.transport.wifi_direct_enabled = false;
1429        config.transport.internet_enabled = false;
1430        assert!(config.validate().is_err());
1431    }
1432
1433    #[test]
1434    fn test_config_validation_retry_initial_delay_must_be_positive() {
1435        let mut config = ProtocolConfig::new("test-app", "user123");
1436        config.reliability.retry.initial_delay_ms = 0;
1437        assert!(config.validate().is_err());
1438    }
1439
1440    #[test]
1441    fn test_config_validation_retry_delay_bounds() {
1442        let mut config = ProtocolConfig::new("test-app", "user123");
1443        config.reliability.retry.initial_delay_ms = 2000;
1444        config.reliability.retry.max_delay_ms = 1000;
1445        assert!(config.validate().is_err());
1446    }
1447
1448    #[test]
1449    fn test_config_validation_retry_backoff_multiplier_bounds() {
1450        let mut config = ProtocolConfig::new("test-app", "user123");
1451        config.reliability.retry.backoff_multiplier = 0.5;
1452        assert!(config.validate().is_err());
1453    }
1454
1455    #[test]
1456    fn test_config_validation_rejects_zero_bloom_parameters() {
1457        let mutations: [fn(&mut DeduplicatorConfig); 4] = [
1458            |dedup: &mut DeduplicatorConfig| dedup.bloom_filter_bits = 0,
1459            |dedup: &mut DeduplicatorConfig| dedup.bloom_hash_count = 0,
1460            |dedup: &mut DeduplicatorConfig| dedup.bloom_filter_count = 0,
1461            |dedup: &mut DeduplicatorConfig| dedup.bloom_rotation_secs = 0,
1462        ];
1463
1464        for mutate in mutations {
1465            let mut config = ProtocolConfig::new("test-app", "user123");
1466            config.reliability.dedup.use_bloom_filter = true;
1467            mutate(&mut config.reliability.dedup);
1468            assert!(config.validate().is_err());
1469        }
1470    }
1471
1472    #[test]
1473    fn test_config_builder() {
1474        let config = ProtocolConfig::builder("test-app", "user123")
1475            .ble_enabled(true)
1476            .wifi_direct_enabled(false)
1477            .online_first(true)
1478            .initial_ttl(10)
1479            .build()
1480            .unwrap();
1481
1482        assert_eq!(config.app_id, "test-app");
1483        assert!(config.transport.ble_enabled);
1484        assert!(!config.transport.wifi_direct_enabled);
1485        assert!(config.dors.prefer_online);
1486        assert_eq!(config.initial_ttl, 10);
1487    }
1488
1489    #[test]
1490    fn test_transport_config_default() {
1491        let transport = TransportConfig::default();
1492        assert!(transport.ble_enabled);
1493        assert!(transport.wifi_direct_enabled);
1494        assert!(transport.internet_enabled);
1495    }
1496
1497    #[test]
1498    fn test_reliability_config_default() {
1499        let reliability = ReliabilityConfig::default();
1500        assert_eq!(reliability.ack.default_timeout_ms, 10000);
1501        assert_eq!(reliability.retry.max_retries, 10);
1502        // 7 days — must match the app-layer presence-flush window and the
1503        // fallback values hardcoded in the RN native bridges
1504        // (OfflineProtocolModule.kt / OfflineProtocolModule.swift).
1505        assert_eq!(reliability.retry.outbox_max_lifetime_ms, 604_800_000);
1506        // 5 min ceiling — also mirrored by the RN bridge fallbacks.
1507        assert_eq!(reliability.retry.max_delay_ms, 300_000);
1508        // Sized for the persisted seen set: see `DeduplicatorConfig::default`.
1509        assert_eq!(reliability.dedup.max_tracked_messages, 2000);
1510        assert_eq!(reliability.dedup.retention_time_secs, 86_400);
1511    }
1512
1513    #[test]
1514    fn test_encryption_config_default() {
1515        let encryption = EncryptionConfig::default();
1516        assert!(encryption.enabled);
1517        assert!(encryption.auto_key_exchange);
1518        assert!(encryption.store_pending);
1519        // SEC-M3: encryption is required by default; plaintext is an
1520        // explicit opt-out, never a silent fallback.
1521        assert!(encryption.require_encryption);
1522        assert_eq!(encryption.pending_queue.max_pending_per_peer, 64);
1523        assert_eq!(encryption.pending_queue.max_pending_global, 4096);
1524        assert_eq!(
1525            encryption.pending_queue.pending_ttl_ms,
1526            DEFAULT_PENDING_TTL_MS
1527        );
1528        assert_eq!(
1529            encryption.pending_queue.overflow_policy,
1530            OverflowPolicy::DropOldest
1531        );
1532    }
1533
1534    #[test]
1535    fn test_encryption_config_disabled() {
1536        let encryption = EncryptionConfig::disabled();
1537        assert!(!encryption.enabled);
1538        assert!(!encryption.auto_key_exchange);
1539        assert!(!encryption.store_pending);
1540        assert!(!encryption.require_encryption);
1541    }
1542
1543    #[test]
1544    fn test_config_builder_with_encryption() {
1545        let config = ProtocolConfig::builder("test-app", "user123")
1546            .encryption_enabled(true)
1547            .auto_key_exchange(true)
1548            .store_pending_messages(false)
1549            .require_encryption(true)
1550            .pending_queue(PendingQueueConfig {
1551                max_pending_per_peer: 32,
1552                max_pending_global: 512,
1553                pending_ttl_ms: 30_000,
1554                overflow_policy: OverflowPolicy::DropNewest,
1555                ..Default::default()
1556            })
1557            .build()
1558            .unwrap();
1559
1560        assert!(config.encryption.enabled);
1561        assert!(config.encryption.auto_key_exchange);
1562        assert!(!config.encryption.store_pending);
1563        assert!(config.encryption.require_encryption);
1564        assert_eq!(config.encryption.pending_queue.max_pending_per_peer, 32);
1565        assert_eq!(config.encryption.pending_queue.max_pending_global, 512);
1566        assert_eq!(config.encryption.pending_queue.pending_ttl_ms, 30_000);
1567        assert_eq!(
1568            config.encryption.pending_queue.overflow_policy,
1569            OverflowPolicy::DropNewest
1570        );
1571    }
1572
1573    #[test]
1574    fn test_config_validation_require_encryption_requires_enabled() {
1575        let mut config = ProtocolConfig::new("test-app", "user123");
1576        config.encryption.enabled = false;
1577        config.encryption.require_encryption = true;
1578        assert!(config.validate().is_err());
1579    }
1580
1581    #[test]
1582    fn test_config_validation_pending_queue_requires_positive_limits() {
1583        let mut config = ProtocolConfig::new("test-app", "user123");
1584        config.encryption.pending_queue.max_pending_per_peer = 0;
1585        assert!(config.validate().is_err());
1586
1587        let mut config = ProtocolConfig::new("test-app", "user123");
1588        config.encryption.pending_queue.max_pending_global = 0;
1589        assert!(config.validate().is_err());
1590
1591        let mut config = ProtocolConfig::new("test-app", "user123");
1592        config.encryption.pending_queue.pending_ttl_ms = 0;
1593        assert!(config.validate().is_err());
1594    }
1595
1596    #[test]
1597    fn test_config_validation_pending_queue_global_must_cover_per_peer() {
1598        let mut config = ProtocolConfig::new("test-app", "user123");
1599        config.encryption.pending_queue.max_pending_per_peer = 100;
1600        config.encryption.pending_queue.max_pending_global = 99;
1601        assert!(config.validate().is_err());
1602    }
1603
1604    #[test]
1605    fn test_group_config_default() {
1606        let group = GroupConfig::default();
1607        assert_eq!(group.max_group_members, 256);
1608        // Registration and broadcast both default on. Registration must stay
1609        // on because invite links resolve against the relay group registry.
1610        // The broadcast default is safe because the flag alone never selects
1611        // the path: it is additionally gated on the relay advertising the
1612        // `group_delivery_v3` capability, whose settled, address-space
1613        // delivery report is what gives the broadcast a per-recipient
1614        // delivery contract.
1615        assert!(group.relay_enabled);
1616        assert!(group.relay_broadcast_enabled);
1617        // Commit enforcement must stay OFF by default. On, a member whose
1618        // admin overlay merely lagged would refuse a legitimate commit and
1619        // fork itself out of a healthy group with no attacker involved —
1620        // unrecoverable without an app-level re-invite. Flipping this default
1621        // is a fleet-wide partition decision, not a hardening tweak.
1622        assert!(!group.enforce_admin_commits);
1623    }
1624
1625    #[test]
1626    fn test_group_enforce_admin_commits_builder_opt_in() {
1627        let config = ProtocolConfig::builder("test-app", "user123")
1628            .group_enforce_admin_commits(true)
1629            .build()
1630            .unwrap();
1631        assert!(config.group.enforce_admin_commits);
1632        assert!(
1633            config.group.relay_enabled && config.group.relay_broadcast_enabled,
1634            "opting into enforcement must not disturb the relay flags"
1635        );
1636    }
1637
1638    #[test]
1639    fn test_group_relay_broadcast_builder_opt_out() {
1640        let config = ProtocolConfig::builder("test-app", "user123")
1641            .group_relay_broadcast_enabled(false)
1642            .build()
1643            .unwrap();
1644        assert!(!config.group.relay_broadcast_enabled);
1645        assert!(
1646            config.group.relay_enabled,
1647            "opting out of the broadcast must not disturb registration"
1648        );
1649    }
1650
1651    #[test]
1652    fn test_config_has_default_group_config() {
1653        let config = ProtocolConfig::new("test-app", "user123");
1654        assert_eq!(config.group.max_group_members, 256);
1655    }
1656
1657    #[test]
1658    fn test_config_validation_zero_max_group_members() {
1659        let mut config = ProtocolConfig::new("test-app", "user123");
1660        config.group.max_group_members = 0;
1661        assert!(config.validate().is_err());
1662    }
1663
1664    #[test]
1665    fn test_config_validation_positive_max_group_members() {
1666        let mut config = ProtocolConfig::new("test-app", "user123");
1667        config.group.max_group_members = 1;
1668        assert!(config.validate().is_ok());
1669    }
1670
1671    /// A handicap large enough to outrun the overdue cut-off must be refused.
1672    ///
1673    /// Past that point a biased forward is not late, it is *abandoned* on
1674    /// release — so the tunable that exists to cost redundancy would be costing
1675    /// delivery, silently, on exactly the devices it is meant to spare.
1676    #[test]
1677    fn test_config_validation_rejects_a_handicap_past_the_overdue_cutoff() {
1678        let mut config = ProtocolConfig::new("test-app", "user123");
1679        config.mesh_relay.bias_max_handicap = RELAY_QUEUE_MAX_OVERDUE;
1680        assert!(config.validate().is_err());
1681
1682        // The jitter window counts toward the same budget, so a handicap that
1683        // fits on its own can still be refused alongside a wide window.
1684        config.mesh_relay.bias_max_handicap = std::time::Duration::from_millis(400);
1685        config.mesh_relay.jitter_max = RELAY_QUEUE_MAX_OVERDUE;
1686        assert!(config.validate().is_err());
1687
1688        // And the defaults must sit comfortably inside it.
1689        let defaults = ProtocolConfig::new("test-app", "user123");
1690        assert!(defaults.validate().is_ok());
1691    }
1692
1693    /// Bias must never be configurable into an off switch.
1694    ///
1695    /// A scale of zero would stop a low-battery device forwarding by the back
1696    /// door, which is the partition the whole bias design exists to avoid —
1697    /// declining to relay is `relay.allow_relay`'s job, and it is visible.
1698    #[test]
1699    fn test_config_validation_rejects_a_bias_scale_that_silences_forwarding() {
1700        for scale in [0.0, -0.5, 1.5, f32::NAN, f32::INFINITY] {
1701            let mut config = ProtocolConfig::new("test-app", "user123");
1702            config.mesh_relay.bias_min_scale = scale;
1703            assert!(
1704                config.validate().is_err(),
1705                "bias_min_scale of {scale} must be refused",
1706            );
1707        }
1708
1709        // 1.0 is the documented way to disable bias entirely, and must stay
1710        // accepted.
1711        let mut config = ProtocolConfig::new("test-app", "user123");
1712        config.mesh_relay.bias_min_scale = 1.0;
1713        assert!(config.validate().is_ok());
1714    }
1715
1716    /// A fan-out of zero must be refused rather than clamped.
1717    ///
1718    /// It reads like an off switch and is not one: the frame is already
1719    /// admitted and recorded as seen by the time fan-out is chosen, so zero
1720    /// targets is a silent drop that also suppresses the copy arriving by
1721    /// another path. The forwarding paths clamp it to one; this keeps that
1722    /// clamp a defensive floor rather than a quiet reinterpretation.
1723    #[test]
1724    fn test_config_validation_rejects_a_fanout_of_zero() {
1725        let mut config = ProtocolConfig::new("test-app", "user123");
1726        config.mesh_relay.fanout = 0;
1727        assert!(config.validate().is_err());
1728
1729        // One is a real setting — forward to a single neighbor — and must stay
1730        // accepted, or the check has swallowed the narrowest working fan-out.
1731        config.mesh_relay.fanout = 1;
1732        assert!(config.validate().is_ok());
1733    }
1734
1735    /// Every hop-budget, queue and rate dial that can silently switch
1736    /// forwarding off must be refused, not clamped.
1737    ///
1738    /// These share one failure and it is the worst kind: the device keeps
1739    /// running, reports no error, and carries nothing. Each value below is the
1740    /// one a caller would reach for meaning "be conservative", so leaving them
1741    /// legal makes an off switch out of a dial nobody thinks is one.
1742    #[test]
1743    fn test_config_validation_rejects_dials_that_silently_stop_forwarding() {
1744        // Both hop ceilings: zero clamps every arriving budget to nothing, so
1745        // the frame is refused before it can be queued. The dense one is
1746        // checked separately because it only bites in a crowded room.
1747        let mut zero_ttl = ProtocolConfig::new("test-app", "user123");
1748        zero_ttl.mesh_relay.max_ttl = 0;
1749        assert!(zero_ttl.validate().is_err());
1750
1751        let mut zero_dense_ttl = ProtocolConfig::new("test-app", "user123");
1752        zero_dense_ttl.mesh_relay.dense_max_ttl = 0;
1753        assert!(zero_dense_ttl.validate().is_err());
1754
1755        // A queue that holds nothing rejects every admission as queue-full.
1756        let mut zero_queue = ProtocolConfig::new("test-app", "user123");
1757        zero_queue.mesh_relay.queue_capacity = 0;
1758        assert!(zero_queue.validate().is_err());
1759
1760        // The token buckets clamp their inputs with `.max(0.0)`, which turns
1761        // each of these into a bucket that never yields a token. NaN is listed
1762        // on its own because `f32::max` returns the other operand for it, so it
1763        // arrives as zero rather than failing anywhere visible, and negatives
1764        // are what a bridge's own clamp hands over for validation here.
1765        for bad in [0.0_f32, -1.0, f32::NAN, f32::INFINITY] {
1766            let mut rate = ProtocolConfig::new("test-app", "user123");
1767            rate.mesh_relay.rate_per_sec = bad;
1768            assert!(
1769                rate.validate().is_err(),
1770                "rate_per_sec of {bad} must be refused",
1771            );
1772
1773            let mut burst = ProtocolConfig::new("test-app", "user123");
1774            burst.mesh_relay.burst = bad;
1775            assert!(burst.validate().is_err(), "burst of {bad} must be refused");
1776
1777            let mut peer_rate = ProtocolConfig::new("test-app", "user123");
1778            peer_rate.mesh_relay.peer_rate_per_sec = bad;
1779            assert!(
1780                peer_rate.validate().is_err(),
1781                "peer_rate_per_sec of {bad} must be refused",
1782            );
1783
1784            let mut peer_burst = ProtocolConfig::new("test-app", "user123");
1785            peer_burst.mesh_relay.peer_burst = bad;
1786            assert!(
1787                peer_burst.validate().is_err(),
1788                "peer_burst of {bad} must be refused",
1789            );
1790        }
1791
1792        // The narrowest working settings must stay accepted, or the checks have
1793        // swallowed real configurations along with the broken ones.
1794        let mut minimal = ProtocolConfig::new("test-app", "user123");
1795        minimal.mesh_relay.max_ttl = 1;
1796        minimal.mesh_relay.dense_max_ttl = 1;
1797        minimal.mesh_relay.queue_capacity = 1;
1798        minimal.mesh_relay.rate_per_sec = 0.5;
1799        minimal.mesh_relay.burst = 1.0;
1800        minimal.mesh_relay.peer_rate_per_sec = 0.5;
1801        minimal.mesh_relay.peer_burst = 1.0;
1802        assert!(minimal.validate().is_ok());
1803    }
1804
1805    /// An inverted jitter window must be refused.
1806    ///
1807    /// Two failures in one, which is why the equal case has to stay legal to
1808    /// prove the check is an ordering check and not a ban on narrow windows.
1809    /// `jitter_for` raises the effective maximum to meet the minimum, so the
1810    /// span collapses to a millisecond and neighbors stop separating in time;
1811    /// and since the overdue check reads `jitter_max`, an oversized minimum
1812    /// would otherwise validate clean and abandon every forward.
1813    #[test]
1814    fn test_config_validation_rejects_an_inverted_jitter_window() {
1815        let mut inverted = ProtocolConfig::new("test-app", "user123");
1816        inverted.mesh_relay.jitter_min = std::time::Duration::from_millis(300);
1817        inverted.mesh_relay.jitter_max = std::time::Duration::from_millis(200);
1818        assert!(inverted.validate().is_err());
1819
1820        // The case the overdue check alone would have missed: a minimum past
1821        // the 5s cut-off, with the maximum left at its default.
1822        let mut oversized_min = ProtocolConfig::new("test-app", "user123");
1823        oversized_min.mesh_relay.jitter_min = std::time::Duration::from_secs(600);
1824        assert!(oversized_min.validate().is_err());
1825
1826        // Equal is a fixed delay, not an inversion, and stays accepted.
1827        let mut equal = ProtocolConfig::new("test-app", "user123");
1828        equal.mesh_relay.jitter_min = std::time::Duration::from_millis(200);
1829        equal.mesh_relay.jitter_max = std::time::Duration::from_millis(200);
1830        assert!(equal.validate().is_ok());
1831    }
1832
1833    /// The activity window's tunables decide whether the relay standing can be
1834    /// reported at all; a zero in any of them stalls or divides it.
1835    #[test]
1836    fn test_config_validation_rejects_zero_activity_tunables() {
1837        let mut zero_window = ProtocolConfig::new("test-app", "user123");
1838        zero_window.mesh_relay.activity_window = std::time::Duration::ZERO;
1839        assert!(zero_window.validate().is_err());
1840
1841        let mut zero_forwards = ProtocolConfig::new("test-app", "user123");
1842        zero_forwards.mesh_relay.activity_min_forwards = 0;
1843        assert!(zero_forwards.validate().is_err());
1844
1845        let mut zero_idle = ProtocolConfig::new("test-app", "user123");
1846        zero_idle.mesh_relay.activity_idle_windows = 0;
1847        assert!(zero_idle.validate().is_err());
1848    }
1849
1850    #[test]
1851    fn test_config_builder_max_group_members() {
1852        let config = ProtocolConfig::builder("test-app", "user123")
1853            .max_group_members(50)
1854            .build()
1855            .unwrap();
1856        assert_eq!(config.group.max_group_members, 50);
1857    }
1858
1859    #[test]
1860    fn test_config_builder_group_config() {
1861        let config = ProtocolConfig::builder("test-app", "user123")
1862            .group(GroupConfig {
1863                max_group_members: 128,
1864                ..Default::default()
1865            })
1866            .build()
1867            .unwrap();
1868        assert_eq!(config.group.max_group_members, 128);
1869    }
1870
1871    #[test]
1872    fn test_config_builder_zero_max_group_members_rejected() {
1873        let result = ProtocolConfig::builder("test-app", "user123")
1874            .max_group_members(0)
1875            .build();
1876        assert!(result.is_err());
1877    }
1878
1879    /// The RN bridges never reach [`DEFAULT_PENDING_TTL_MS`]: each layer
1880    /// substitutes its own fallback when the app omits `pendingTtlMs`, and the
1881    /// JS layer always materializes the field before it crosses the bridge. So
1882    /// the Rust default is only the RN default for as long as these three
1883    /// literals agree with it — they silently drifted to the pre-deferred-ACK
1884    /// 2-minute window once already, which is the window this constant was
1885    /// raised to replace.
1886    ///
1887    /// The sibling guard for the ACK/retry fallbacks lives in
1888    /// `offline-protocol-reliability`; this one has to live here because that
1889    /// crate cannot depend on this one.
1890    #[test]
1891    fn rn_bridge_pending_ttl_fallbacks_match_rust_default() {
1892        let rn_root =
1893            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bindings/react-native");
1894
1895        // Each entry is (path, the fallback that file must contain). The
1896        // pattern names the field it guards, not just the literal, so deleting
1897        // the fallback cannot be masked by the same number appearing on some
1898        // other line. Matched against the source with whitespace squeezed, so a
1899        // line wrap between the field and its `?:`/`??` doesn't hide it.
1900        let expected = [
1901            (
1902                rn_root.join("src/index.ts"),
1903                format!("pendingTtlMs ?? {DEFAULT_PENDING_TTL_MS}"),
1904            ),
1905            (
1906                rn_root.join("android/src/main/java/com/offlineprotocol/ProtocolConfigParser.kt"),
1907                format!(
1908                    "\"pending_ttl_ms\") ?: {}L",
1909                    format_underscored(DEFAULT_PENDING_TTL_MS)
1910                ),
1911            ),
1912            (
1913                rn_root.join("ios/EncryptionConfigReader.swift"),
1914                format!(
1915                    "\"pending_ttl_ms\") ?? {}",
1916                    format_underscored(DEFAULT_PENDING_TTL_MS)
1917                ),
1918            ),
1919        ];
1920
1921        // The guard only applies in the repo checkout; skip when the bindings
1922        // tree isn't present (e.g. a vendored crate). Read every file up front
1923        // so a moved or renamed bridge skips the whole guard rather than
1924        // silently dropping the checks that come after it.
1925        let Some(sources) = expected
1926            .iter()
1927            .map(|(path, _)| std::fs::read_to_string(path).ok())
1928            .collect::<Option<Vec<_>>>()
1929        else {
1930            eprintln!("bindings tree not present, skipping RN pending-TTL drift check");
1931            return;
1932        };
1933
1934        for ((path, fallback), source) in expected.iter().zip(&sources) {
1935            let squeezed = source.split_whitespace().collect::<Vec<_>>().join(" ");
1936            assert!(
1937                squeezed.contains(fallback.as_str()),
1938                "RN pending-queue TTL fallback drifted from DEFAULT_PENDING_TTL_MS: \
1939                 expected `{fallback}` in {}",
1940                path.display()
1941            );
1942        }
1943    }
1944
1945    /// Drift guard: the React Native `updateDedupConfig` fallbacks match the
1946    /// core dedup defaults. The TypeScript layer forwards only the fields the
1947    /// app set, so a `reliability.dedup` section that sets one field takes the
1948    /// other from these literals, and a fallback that disagrees with the
1949    /// documented default silently changes it for that app. The id cap sat at
1950    /// 10000 against a documented default of 1000, then 2000, that way.
1951    #[test]
1952    fn rn_bridge_dedup_fallbacks_match_rust_default() {
1953        let defaults = ProtocolConfig::new("test-app", "user123").reliability.dedup;
1954        let rn_root =
1955            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bindings/react-native");
1956        let kotlin =
1957            rn_root.join("android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt");
1958        let swift = rn_root.join("ios/OfflineProtocolModule.swift");
1959        let expected = [
1960            (
1961                kotlin.clone(),
1962                format!(
1963                    "json.optLong(\"maxTrackedMessages\", {})",
1964                    defaults.max_tracked_messages
1965                ),
1966            ),
1967            (
1968                kotlin,
1969                format!(
1970                    "json.optLong(\"retentionTimeSecs\", {})",
1971                    defaults.retention_time_secs
1972                ),
1973            ),
1974            (
1975                swift.clone(),
1976                format!(
1977                    "(config[\"maxTrackedMessages\"] as? NSNumber)?.uint64Value ?? {}",
1978                    defaults.max_tracked_messages
1979                ),
1980            ),
1981            (
1982                swift,
1983                format!(
1984                    "(config[\"retentionTimeSecs\"] as? NSNumber)?.uint64Value ?? {}",
1985                    defaults.retention_time_secs
1986                ),
1987            ),
1988        ];
1989
1990        let Some(sources) = expected
1991            .iter()
1992            .map(|(path, _)| std::fs::read_to_string(path).ok())
1993            .collect::<Option<Vec<_>>>()
1994        else {
1995            eprintln!("bindings tree not present, skipping RN dedup fallback drift check");
1996            return;
1997        };
1998
1999        for ((path, fallback), source) in expected.iter().zip(&sources) {
2000            let squeezed = source.split_whitespace().collect::<Vec<_>>().join(" ");
2001            assert!(
2002                squeezed.contains(fallback.as_str()),
2003                "RN dedup fallback drifted from the core default: expected `{fallback}` in {}",
2004                path.display()
2005            );
2006        }
2007    }
2008
2009    /// Drift guard: every public surface that documents
2010    /// [`EncryptionConfig::crypto_recovery_enabled`] must state that the re-key
2011    /// trigger is unauthenticated, and must not carry the retired claim that it
2012    /// re-keys only on genuine peer traffic.
2013    ///
2014    /// This is guarded because it has already drifted once. The trigger was
2015    /// hardened and honestly documented in-code, but that pass corrected only
2016    /// `session.rs` and the internal architecture notes — six integrator-facing
2017    /// surfaces kept promising that an injected frame could never drive a
2018    /// re-key, and framed the residual as replay-only, while
2019    /// `test_forged_frame_reaches_session_desync_without_any_key_material` in
2020    /// `offline-protocol-mls` proved the opposite in the same workspace. Nothing
2021    /// else fails when these lag: the code stays correct and the docs quietly
2022    /// promise a guarantee it does not make, which is the worst shape for a
2023    /// security claim to fail in.
2024    #[test]
2025    fn crypto_recovery_docs_state_the_unauthenticated_trigger() {
2026        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
2027        let surfaces = [
2028            "crates/offline-protocol/src/config.rs",
2029            "crates/offline-protocol-uniffi/src/offline_protocol.udl",
2030            "bindings/react-native/src/types.ts",
2031            "docs/mls-integration.md",
2032            "docs/configuration.md",
2033            "docs/api-reference.md",
2034        ];
2035
2036        // Retired claims. Each was false for the same reason: an MLS epoch is
2037        // read during framing validation, before the sender is authenticated.
2038        // Split across `concat!` so the banned phrases never appear verbatim in
2039        // this file — belt-and-braces on top of the `#[cfg(test)]` truncation
2040        // below, so the guard survives either defense being refactored away.
2041        let retired = [
2042            concat!("never re-keys on ", "injected garbage"),
2043            concat!("injected garbage ", "can't drive"),
2044            concat!("injecting ", "garbage"),
2045        ];
2046
2047        // The marker every surface must carry, concat-split for the inverse
2048        // reason: written verbatim, this needle alone would satisfy its own
2049        // check for config.rs no matter what the rustdoc says.
2050        let marker = concat!("re-key trigger is ", "unauthenticated");
2051
2052        for surface in surfaces {
2053            let path = root.join(surface);
2054            let source = std::fs::read_to_string(&path)
2055                .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
2056            // Scan only up to the first `#[cfg(test)]` token — for config.rs,
2057            // the non-test source. This test necessarily names the marker (doc
2058            // comment, panic message), so scanning its own module would satisfy
2059            // the marker check for the file it lives in even after the rustdoc
2060            // dropped it. The other surfaces don't contain the token and pass
2061            // through whole.
2062            let scanned = source.split("#[cfg(test)]").next().unwrap_or(&source);
2063            // Squeezed so a line wrap inside a phrase cannot hide it, and
2064            // lowercased so a sentence-initial capital cannot either. Standalone
2065            // comment/markup continuation tokens (`///`, `//`, `*`, `>`) are
2066            // dropped, because these six surfaces wrap prose in four different
2067            // comment syntaxes and a reflow would otherwise wedge one into the
2068            // middle of a phrase — a guard that fails on `cargo fmt` teaches
2069            // people to delete it.
2070            let squeezed = scanned
2071                .split_whitespace()
2072                .filter(|tok| !tok.chars().all(|c| matches!(c, '/' | '*' | '>')))
2073                .collect::<Vec<_>>()
2074                .join(" ")
2075                .to_lowercase();
2076
2077            for claim in retired {
2078                assert!(
2079                    !squeezed.contains(claim),
2080                    "{surface} carries the retired claim `{claim}`. The re-key \
2081                     trigger is reachable by anyone who can inject a frame — see \
2082                     test_forged_frame_reaches_session_desync_without_any_key_material"
2083                );
2084            }
2085
2086            assert!(
2087                squeezed.contains(marker),
2088                "{surface} documents crypto_recovery_enabled but no longer states \
2089                 that the re-key trigger is unauthenticated. Say so plainly, or an \
2090                 integrator will read a sustained SESSION_REKEY_TRIGGERED rate as a \
2091                 bug rather than as the injection signal it exists to surface."
2092            );
2093        }
2094    }
2095
2096    /// Renders `86400000` as `86_400_000`, the form Kotlin and Swift use.
2097    fn format_underscored(value: u64) -> String {
2098        let digits = value.to_string();
2099        let mut out = String::new();
2100        for (i, c) in digits.chars().enumerate() {
2101            if i > 0 && (digits.len() - i) % 3 == 0 {
2102                out.push('_');
2103            }
2104            out.push(c);
2105        }
2106        out
2107    }
2108}