Skip to main content

offline_protocol/
config.rs

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