xmtp_mls_common/app_data/custom.rs
1//! Host-defined ("custom") component registration.
2//!
3//! Custom components live in the app range (`0xC000-0xFEFF`,
4//! per [`ComponentId::is_app_range`]) and are registered by host
5//! apps at process startup. Unlike well-known components — which
6//! have static `Component` impls with `const ID: ComponentId` — a
7//! [`RuntimeComponent`] carries its id as a runtime value so a host
8//! can register multiple distinct components from one impl type
9//! parameterized by id (or build them dynamically).
10//!
11//! ## Lifecycle
12//!
13//! 1. **Process startup** (host): the host calls
14//! [`register_global_runtime_component`] for each component it
15//! wants to support. The registry stores a `&'static dyn
16//! ErasedComponent`-shaped handle keyed by `ComponentId`.
17//! 2. **Dispatch lookup**: callers of
18//! [`super::registry_table::lookup_component`] check the static
19//! `WELL_KNOWN` table first; if no entry exists there and the id
20//! is in the app range, the runtime registry is consulted.
21//! 3. **Per-group registration**: registering a custom component in
22//! a *group's* `COMPONENT_REGISTRY` happens via
23//! `IntentKind::UpdatePermission` (post-bootstrap), separately
24//! from the host-process registration here. Both must happen
25//! before a group can carry a custom component's value: the host
26//! needs a `RuntimeComponent` impl to decode the bytes, and the
27//! group needs a registry entry so writes pass
28//! `validate_component_write`.
29//!
30//! ## Why a global rather than per-Client
31//!
32//! Threading a per-`Client` registry through every dispatch site
33//! (`apply_app_data_update_payload`, `expand_app_data_update_to_changes`,
34//! `validate_component_write`, the facade) would touch ~dozens of
35//! call shapes. A process-global registry keeps the dispatch
36//! signature stable and reflects the operational reality: hosts
37//! register their custom-component shapes once, at process startup,
38//! before any group is created. There is no use case for "different
39//! Clients in the same process know different custom components."
40
41use std::{
42 collections::HashMap,
43 sync::{Arc, OnceLock},
44};
45
46use parking_lot::RwLock;
47
48use openmls::messages::proposals::AppDataUpdateOperation;
49use xmtp_common::{MaybeSend, MaybeSync};
50use xmtp_proto::xmtp::mls::message_contents::ComponentType;
51
52use super::{
53 component_id::ComponentId,
54 component_registry::ComponentRegistry,
55 typed::{
56 ComponentInvariantError, ComponentTypedError, ErasedComponent, ExpandedComponentChange,
57 },
58 validation::ComponentChange,
59};
60
61/// A host-defined component whose `ComponentId` is known at runtime.
62///
63/// Unlike [`super::typed::Component`], `RuntimeComponent` takes
64/// `&self` and reports its id via [`Self::id`] so a single impl can
65/// service multiple distinct ids (or build them dynamically). The
66/// trait is otherwise structurally identical to `Component` minus
67/// the typed `Value` / `Mutation` associated types — runtime
68/// components handle bytes only, since the host owns whatever
69/// further decoding it does on top.
70///
71/// The bounds are [`MaybeSend`] + [`MaybeSync`] rather than `Send +
72/// Sync` so that WASM hosts can register impls that hold non-`Send`
73/// state (e.g. JS-bound objects). On native targets these expand to
74/// `Send + Sync` and the trait behaves identically to before; on
75/// `wasm32` they expand to vacuous bounds. The dispatch path's static
76/// storage requirements are bridged in [`RuntimeAdapter`] below — see
77/// the SAFETY note there.
78pub trait RuntimeComponent: MaybeSend + MaybeSync + 'static {
79 /// The component this instance handles.
80 fn id(&self) -> ComponentId;
81
82 /// Logical wire type, written into the registry entry's
83 /// `ComponentMetadata.component_type` slot. The host typically
84 /// picks one of the existing [`ComponentType`] variants
85 /// (`Bytes`, `String`, `TlsSetInboxId`, etc.) — runtime
86 /// components are not currently expected to introduce new
87 /// `ComponentType`s.
88 fn component_type(&self) -> ComponentType;
89
90 /// Apply an `AppDataUpdateOperation::Update` payload against the
91 /// prior bytes (if any) and produce the new full-state bytes.
92 fn apply_update_payload(
93 &self,
94 payload: &[u8],
95 prior: Option<&[u8]>,
96 ) -> Result<Vec<u8>, ComponentTypedError>;
97
98 /// Expand an `AppDataUpdate` proposal into per-element changes
99 /// for the validator's policy loop.
100 fn expand_to_changes(
101 &self,
102 op: &AppDataUpdateOperation,
103 prior: Option<&[u8]>,
104 ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError>;
105
106 /// Optional component-local invariant check. Default no-op.
107 fn validate_invariant(
108 &self,
109 _change: &ComponentChange<'_>,
110 _registry: &ComponentRegistry,
111 ) -> Result<(), ComponentInvariantError> {
112 Ok(())
113 }
114}
115
116/// Adapter so a [`RuntimeComponent`] can be used wherever an
117/// [`ErasedComponent`] is expected. The dispatch table consults
118/// runtime components via this adapter.
119struct RuntimeAdapter(Arc<dyn RuntimeComponent>);
120
121// SAFETY: wasm32 is single-threaded; the runtime registry's
122// `Arc<dyn RuntimeComponent>` is only ever accessed from the same
123// thread that registered it. On native, `RuntimeComponent` carries
124// `Send + Sync` (via `MaybeSend + MaybeSync`) so this impl is
125// unnecessary — only WASM needs the manual bridge because the trait
126// bounds are vacuous there but `ErasedComponent` (used by the static
127// dispatch table) still requires `Send + Sync`.
128#[cfg(target_arch = "wasm32")]
129unsafe impl Send for RuntimeAdapter {}
130#[cfg(target_arch = "wasm32")]
131unsafe impl Sync for RuntimeAdapter {}
132
133impl ErasedComponent for RuntimeAdapter {
134 fn id(&self) -> ComponentId {
135 self.0.id()
136 }
137
138 fn component_type(&self) -> ComponentType {
139 self.0.component_type()
140 }
141
142 fn apply_update_payload(
143 &self,
144 payload: &[u8],
145 prior: Option<&[u8]>,
146 ) -> Result<Vec<u8>, ComponentTypedError> {
147 self.0.apply_update_payload(payload, prior)
148 }
149
150 fn expand_to_changes(
151 &self,
152 op: &AppDataUpdateOperation,
153 prior: Option<&[u8]>,
154 ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
155 self.0.expand_to_changes(op, prior)
156 }
157
158 fn validate_invariant(
159 &self,
160 change: &ComponentChange<'_>,
161 registry: &ComponentRegistry,
162 ) -> Result<(), ComponentInvariantError> {
163 self.0.validate_invariant(change, registry)
164 }
165}
166
167/// Errors surfaced by [`register_global_runtime_component`].
168#[derive(Debug, thiserror::Error)]
169pub enum RuntimeRegistrationError {
170 /// The id is outside the app range (`0xC000-0xFEFF`).
171 /// XMTP-range ids are reserved for static well-known impls.
172 #[error("component id {0} is not in the app-defined range (0xC000-0xFEFF)")]
173 OutOfRange(ComponentId),
174
175 /// The id is already registered. Re-registration is rejected
176 /// unconditionally — even with the same impl — because
177 /// `Arc<dyn>` and `&'static dyn` have different fat-pointer
178 /// layouts, so we can't cheaply detect "same impl" without
179 /// extra bookkeeping. Hosts should `Once`-gate their startup
180 /// registration paths.
181 #[error("component id {0} is already registered")]
182 AlreadyRegistered(ComponentId),
183}
184
185/// Process-global runtime component registry.
186///
187/// Stores `Arc<dyn RuntimeComponent>` keyed by `ComponentId`. The
188/// adapter wrapping each entry into `&'static dyn ErasedComponent`
189/// is also cached so the dispatch path can hand back a stable
190/// `&'static` reference. Updates happen at process startup; reads
191/// happen on every commit's validation path.
192struct RuntimeRegistry {
193 inner: RwLock<HashMap<ComponentId, &'static dyn ErasedComponent>>,
194}
195
196impl RuntimeRegistry {
197 fn new() -> Self {
198 Self {
199 inner: RwLock::new(HashMap::new()),
200 }
201 }
202
203 fn register(
204 &self,
205 component: Arc<dyn RuntimeComponent>,
206 ) -> Result<(), RuntimeRegistrationError> {
207 let id = component.id();
208 if !id.is_app_range() {
209 return Err(RuntimeRegistrationError::OutOfRange(id));
210 }
211 let mut guard = self.inner.write();
212 if let Some(_existing) = guard.get(&id) {
213 // Bypass the duplicate check if pointer-equal: re-
214 // registering the same impl is harmless and lets hosts
215 // call `register_global_runtime_component` from
216 // idempotent setup paths.
217 //
218 // We can't compare the underlying `Arc<dyn ...>`
219 // pointers directly because `&'static dyn` and
220 // `Arc<dyn>` have different fat-pointer layouts. For
221 // now treat any second registration as an error;
222 // hosts can `Once`-gate their setup.
223 return Err(RuntimeRegistrationError::AlreadyRegistered(id));
224 }
225 // Leak the adapter to get a `&'static` — runtime components
226 // live for the process lifetime by design (registered at
227 // startup, never unregistered). `Box::leak` of an `Arc`
228 // adapter is the simplest way to mint a static reference
229 // that the dispatch table can hand back.
230 let adapter: Box<dyn ErasedComponent> = Box::new(RuntimeAdapter(component));
231 let leaked: &'static dyn ErasedComponent = Box::leak(adapter);
232 guard.insert(id, leaked);
233 Ok(())
234 }
235
236 fn lookup(&self, id: ComponentId) -> Option<&'static dyn ErasedComponent> {
237 self.inner.read().get(&id).copied()
238 }
239}
240
241fn registry() -> &'static RuntimeRegistry {
242 static REGISTRY: OnceLock<RuntimeRegistry> = OnceLock::new();
243 REGISTRY.get_or_init(RuntimeRegistry::new)
244}
245
246/// Register a host-defined runtime component.
247///
248/// Call once per component at process startup, before any
249/// `Client` operates on a group that may carry this component.
250/// Returns `Err(OutOfRange)` if the id is outside `0xC000-0xFEFF`,
251/// `Err(AlreadyRegistered)` if a different impl is already
252/// registered for the same id.
253///
254/// The component lives for the process lifetime — there is no
255/// `unregister`. Hosts that need to swap impls should restart.
256pub fn register_global_runtime_component(
257 component: Arc<dyn RuntimeComponent>,
258) -> Result<(), RuntimeRegistrationError> {
259 registry().register(component)
260}
261
262/// Look up a runtime-registered component by id.
263///
264/// Returns `None` if the id is not in the app range (those go
265/// through the static `WELL_KNOWN` dispatch table) or if no host
266/// has registered a `RuntimeComponent` for the id yet.
267pub(crate) fn lookup_runtime_component(id: ComponentId) -> Option<&'static dyn ErasedComponent> {
268 if !id.is_app_range() {
269 return None;
270 }
271 registry().lookup(id)
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::app_data::component_registry::ComponentOp;
278
279 /// A test-only RuntimeComponent that just passes Bytes through.
280 /// Each instance carries its own id so different test cases
281 /// don't collide on the global registry.
282 struct PassthroughBytes(ComponentId);
283
284 impl RuntimeComponent for PassthroughBytes {
285 fn id(&self) -> ComponentId {
286 self.0
287 }
288
289 fn component_type(&self) -> ComponentType {
290 ComponentType::Bytes
291 }
292
293 fn apply_update_payload(
294 &self,
295 payload: &[u8],
296 _prior: Option<&[u8]>,
297 ) -> Result<Vec<u8>, ComponentTypedError> {
298 Ok(payload.to_vec())
299 }
300
301 fn expand_to_changes(
302 &self,
303 op: &AppDataUpdateOperation,
304 _prior: Option<&[u8]>,
305 ) -> Result<Vec<ExpandedComponentChange>, ComponentTypedError> {
306 match op {
307 AppDataUpdateOperation::Update(p) => Ok(vec![ExpandedComponentChange {
308 op: ComponentOp::Update,
309 value: Some(p.as_slice().to_vec()),
310 }]),
311 AppDataUpdateOperation::Remove => Ok(vec![ExpandedComponentChange {
312 op: ComponentOp::Delete,
313 value: None,
314 }]),
315 }
316 }
317 }
318
319 #[xmtp_common::test(unwrap_try = true)]
320 fn rejects_out_of_range_ids() {
321 let well_known_id = ComponentId::GROUP_NAME; // XMTP range
322 let err = register_global_runtime_component(Arc::new(PassthroughBytes(well_known_id)))
323 .unwrap_err();
324 assert!(matches!(
325 err,
326 RuntimeRegistrationError::OutOfRange(id) if id == well_known_id
327 ));
328 }
329
330 #[xmtp_common::test(unwrap_try = true)]
331 fn registers_and_dispatches_through_lookup() {
332 // Pick a unique id per test run to avoid cross-test
333 // collisions on the process-global registry. 0xC100 is
334 // arbitrary but stays in the app range.
335 let id = ComponentId::new(0xC100);
336
337 // First registration succeeds.
338 register_global_runtime_component(Arc::new(PassthroughBytes(id))).unwrap();
339
340 // Lookup returns an ErasedComponent that handles the id.
341 let entry = lookup_runtime_component(id).expect("registered component should look up");
342 assert_eq!(entry.id(), id);
343 assert_eq!(entry.component_type(), ComponentType::Bytes);
344
345 // Apply path works.
346 let new = entry.apply_update_payload(b"hello", None).unwrap();
347 assert_eq!(new, b"hello");
348 }
349
350 #[xmtp_common::test(unwrap_try = true)]
351 fn duplicate_registration_rejected() {
352 // Use a different id than `registers_and_dispatches_through_lookup`
353 // so test ordering doesn't matter.
354 let id = ComponentId::new(0xC101);
355 register_global_runtime_component(Arc::new(PassthroughBytes(id))).unwrap();
356
357 let err = register_global_runtime_component(Arc::new(PassthroughBytes(id))).unwrap_err();
358 assert!(matches!(
359 err,
360 RuntimeRegistrationError::AlreadyRegistered(seen) if seen == id
361 ));
362 }
363
364 #[xmtp_common::test(unwrap_try = true)]
365 fn lookup_returns_none_for_unregistered_id() {
366 // 0xC1FF is reserved for "no test ever uses this" — so
367 // this lookup deterministically returns None even when
368 // tests run in arbitrary order.
369 let unregistered = ComponentId::new(0xC1FF);
370 assert!(lookup_runtime_component(unregistered).is_none());
371
372 // XMTP-range ids always return None from the runtime path
373 // (they're handled by the static dispatch table instead).
374 assert!(lookup_runtime_component(ComponentId::GROUP_NAME).is_none());
375 }
376}