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