Skip to main content

rightkit_daemon/
registration.rs

1//! Service registration (PK07-5): an app declares **who it is**, **which methods it offers** and
2//! **what handles each one**, and RightKit owns the rest: the endpoint, peer authentication,
3//! admission, lifecycle, bounded connections and events, and shutdown.
4//!
5//! ```text
6//! let registration = Registration::new(ServiceIdentity::new("mail-agent", "engine", 5, "1.0")?)
7//!     .method(MethodDescriptor::local("mail.list", ["mailright"]), |cx, params, _emit| {
8//!         Ok(json!({ "app": cx.app(), "params": params }))
9//!     })?
10//!     .method(MethodDescriptor::local("mail.send", ["mailright"]), handle_send)?;
11//! let service = Service::builder()
12//!     .register(&suite, registration, HostOptions::new())?
13//!     .start()?;
14//! ```
15//!
16//! A [`Registration`] is a value, not a framework: identity plus a [`MethodTable`] plus one closure
17//! per method, checked together when built (a method needs a descriptor, an id is not duplicated
18//! or reserved, a method that is reachable by remote devices needs the
19//! [`TransportProfile::LocalAndRemote`] profile). It adds no transport. The only listener a
20//! service has is the local one [`Service::builder`](crate::Service::builder) already hosts (a
21//! current-user-only socket or pipe); nothing here opens a network address, and a service
22//! registered with [`TransportProfile::Local`] refuses `ServiceBuilder::remote` at `start`. That
23//! is what lets an app satisfy "hosts a service" without registering a public listener.
24//!
25//! The method table remains the one source of method policy: the local allowlist and, with the
26//! `remote` feature, the device route table are both derived from it, exactly as for a hand-built
27//! [`Handler`].
28
29use std::collections::BTreeMap;
30use std::fmt;
31use std::sync::Arc;
32
33use serde_json::Value;
34
35use crate::contract::{validate_method_id, CallContext, Emit, Handler, MethodPolicy, MethodTable};
36use crate::contract::{TableError, Transport};
37use crate::error::ServiceError;
38use crate::server::ServiceConfig;
39use crate::suite::valid_service_name;
40use crate::Allowlist;
41
42/// Which transports a registered service may be offered on.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[non_exhaustive]
45pub enum TransportProfile {
46    /// The local transport only. No method may be reachable by a remote device, and the builder
47    /// refuses a remote daemon.
48    Local,
49    /// The local transport, and signed remote devices for the methods whose policy names them.
50    LocalAndRemote,
51}
52
53/// Who the service is: the identity published in `service.json` and used for ranking and takeover.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ServiceIdentity {
56    service: String,
57    role: String,
58    rank: u8,
59    version: String,
60}
61
62fn plain_text(text: &str) -> bool {
63    !text.is_empty() && text.len() <= 64 && text.chars().all(|c| !c.is_control())
64}
65
66impl ServiceIdentity {
67    /// `service` must be a valid service name ([`valid_service_name`]); `role` and `version` must
68    /// be short, non-empty and free of control characters. Lower `rank` wins a takeover.
69    pub fn new(
70        service: &str,
71        role: &str,
72        rank: u8,
73        version: &str,
74    ) -> Result<Self, RegistrationError> {
75        if !valid_service_name(service) {
76            return Err(RegistrationError::BadIdentity("service name"));
77        }
78        if !plain_text(role) {
79            return Err(RegistrationError::BadIdentity("role"));
80        }
81        if !plain_text(version) {
82            return Err(RegistrationError::BadIdentity("version"));
83        }
84        Ok(Self {
85            service: service.to_string(),
86            role: role.to_string(),
87            rank,
88            version: version.to_string(),
89        })
90    }
91
92    pub fn service(&self) -> &str {
93        &self.service
94    }
95
96    pub fn role(&self) -> &str {
97        &self.role
98    }
99
100    pub fn rank(&self) -> u8 {
101        self.rank
102    }
103
104    pub fn version(&self) -> &str {
105        &self.version
106    }
107
108    /// The host configuration for this identity. The allowlist is empty here and replaced by the
109    /// one the method table implies when the service starts.
110    pub fn to_config(&self) -> ServiceConfig {
111        ServiceConfig::new(
112            &self.service,
113            &self.role,
114            self.rank,
115            &self.version,
116            Allowlist::new(),
117        )
118    }
119}
120
121/// One method an app offers: its id and who may call it.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct MethodDescriptor {
124    id: String,
125    policy: MethodPolicy,
126}
127
128impl MethodDescriptor {
129    pub fn new(id: impl Into<String>, policy: MethodPolicy) -> Self {
130        Self {
131            id: id.into(),
132            policy,
133        }
134    }
135
136    /// A method local apps named in `apps` may call (`"*"` for any).
137    pub fn local<I, S>(id: impl Into<String>, apps: I) -> Self
138    where
139        I: IntoIterator<Item = S>,
140        S: Into<String>,
141    {
142        Self::new(id, MethodPolicy::local(apps))
143    }
144
145    pub fn id(&self) -> &str {
146        &self.id
147    }
148
149    pub fn policy(&self) -> &MethodPolicy {
150        &self.policy
151    }
152}
153
154/// Why a registration was refused.
155#[derive(Debug, Clone, PartialEq, Eq)]
156#[non_exhaustive]
157pub enum RegistrationError {
158    /// A part of the identity is not acceptable (`"service name"`, `"role"`, `"version"`).
159    BadIdentity(&'static str),
160    /// The method id or policy was refused by the method table.
161    Table(TableError),
162    /// `ping` is answered by the host and cannot be registered.
163    Reserved(String),
164    /// A method id was registered twice.
165    Duplicate(String),
166    /// A method is reachable by remote devices but the profile is [`TransportProfile::Local`].
167    RemoteMethodInLocalProfile(String),
168    /// A remote daemon was configured for a service registered with [`TransportProfile::Local`].
169    RemoteNotDeclared,
170    /// No method was registered.
171    NoMethods,
172}
173
174impl fmt::Display for RegistrationError {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        match self {
177            RegistrationError::BadIdentity(part) => {
178                write!(f, "service registration: the {part} is not acceptable")
179            }
180            RegistrationError::Table(e) => write!(f, "service registration: {e}"),
181            RegistrationError::Reserved(id) => {
182                write!(f, "service registration: {id:?} is answered by the host")
183            }
184            RegistrationError::Duplicate(id) => {
185                write!(f, "service registration: method {id:?} is registered twice")
186            }
187            RegistrationError::RemoteMethodInLocalProfile(id) => write!(
188                f,
189                "service registration: method {id:?} is reachable by remote devices but the profile is local"
190            ),
191            RegistrationError::RemoteNotDeclared => f.write_str(
192                "service registration: a remote daemon was configured for a local-only registration",
193            ),
194            RegistrationError::NoMethods => {
195                f.write_str("service registration: no method was registered")
196            }
197        }
198    }
199}
200
201impl std::error::Error for RegistrationError {}
202
203impl From<TableError> for RegistrationError {
204    fn from(e: TableError) -> Self {
205        RegistrationError::Table(e)
206    }
207}
208
209type MethodFn =
210    Box<dyn Fn(&CallContext, &Value, &Emit) -> Result<Value, ServiceError> + Send + Sync>;
211
212/// What [`Registration::into_parts`] hands to the builder.
213type Parts = (
214    ServiceIdentity,
215    TransportProfile,
216    MethodTable,
217    Arc<dyn Handler>,
218);
219
220/// Identity, method descriptors and one handler closure per method, checked together. See the
221/// module docs.
222pub struct Registration {
223    identity: ServiceIdentity,
224    transport: TransportProfile,
225    table: MethodTable,
226    handlers: BTreeMap<String, MethodFn>,
227    on_handoff: Option<Box<dyn Fn() + Send + Sync>>,
228    on_connection_closed: Option<Box<dyn Fn(u64) + Send + Sync>>,
229}
230
231impl Registration {
232    /// A registration on the local transport only ([`TransportProfile::Local`]).
233    pub fn new(identity: ServiceIdentity) -> Self {
234        Self {
235            identity,
236            transport: TransportProfile::Local,
237            table: MethodTable::new(),
238            handlers: BTreeMap::new(),
239            on_handoff: None,
240            on_connection_closed: None,
241        }
242    }
243
244    /// Choose the transport profile. Set it before registering methods that name remote scopes.
245    pub fn transport(mut self, transport: TransportProfile) -> Self {
246        self.transport = transport;
247        self
248    }
249
250    /// Register a method and its handler.
251    pub fn method(
252        self,
253        descriptor: MethodDescriptor,
254        handler: impl Fn(&CallContext, &Value, &Emit) -> Result<Value, ServiceError>
255            + Send
256            + Sync
257            + 'static,
258    ) -> Result<Self, RegistrationError> {
259        self.method_boxed(descriptor, Box::new(handler))
260    }
261
262    fn method_boxed(
263        mut self,
264        descriptor: MethodDescriptor,
265        handler: MethodFn,
266    ) -> Result<Self, RegistrationError> {
267        let MethodDescriptor { id, policy } = descriptor;
268        validate_method_id(&id)?;
269        if id == "ping" {
270            return Err(RegistrationError::Reserved(id));
271        }
272        if self.handlers.contains_key(&id) {
273            return Err(RegistrationError::Duplicate(id));
274        }
275        if self.transport == TransportProfile::Local && policy.transport() != Transport::Local {
276            return Err(RegistrationError::RemoteMethodInLocalProfile(id));
277        }
278        // `try_method` consumes the table; the copy keeps `self.table` valid on every path.
279        self.table = self.table.clone().try_method(id.clone(), policy)?;
280        self.handlers.insert(id, handler);
281        Ok(self)
282    }
283
284    /// Called once when a higher-ranked host asks for the service: stop starting new steps.
285    pub fn on_handoff(mut self, hook: impl Fn() + Send + Sync + 'static) -> Self {
286        self.on_handoff = Some(Box::new(hook));
287        self
288    }
289
290    /// Called with the connection id when a connection closes.
291    pub fn on_connection_closed(mut self, hook: impl Fn(u64) + Send + Sync + 'static) -> Self {
292        self.on_connection_closed = Some(Box::new(hook));
293        self
294    }
295
296    pub fn identity(&self) -> &ServiceIdentity {
297        &self.identity
298    }
299
300    pub fn profile(&self) -> TransportProfile {
301        self.transport
302    }
303
304    pub fn methods(&self) -> &MethodTable {
305        &self.table
306    }
307
308    /// The parts a [`ServiceBuilder`](crate::ServiceBuilder) hosts: the identity, the profile, the
309    /// method table and the one handler that dispatches to the registered closures.
310    pub fn into_parts(self) -> Result<Parts, RegistrationError> {
311        if self.handlers.is_empty() {
312            return Err(RegistrationError::NoMethods);
313        }
314        let handler: Arc<dyn Handler> = Arc::new(Registered {
315            handlers: self.handlers,
316            on_handoff: self.on_handoff,
317            on_connection_closed: self.on_connection_closed,
318        });
319        Ok((self.identity, self.transport, self.table, handler))
320    }
321}
322
323/// The handler a [`Registration`] builds: dispatch by method id.
324struct Registered {
325    handlers: BTreeMap<String, MethodFn>,
326    on_handoff: Option<Box<dyn Fn() + Send + Sync>>,
327    on_connection_closed: Option<Box<dyn Fn(u64) + Send + Sync>>,
328}
329
330impl Handler for Registered {
331    fn call(
332        &self,
333        cx: &CallContext,
334        method: &str,
335        params: &Value,
336        emit: &Emit,
337    ) -> Result<Value, ServiceError> {
338        match self.handlers.get(method) {
339            Some(handler) => handler(cx, params, emit),
340            // The allowlist already stops an unregistered method; this is the same answer if a
341            // method id ever reaches the handler by another route.
342            None => Err(ServiceError::new(
343                "unknown_method",
344                format!("{method} is not registered"),
345            )),
346        }
347    }
348
349    fn on_handoff(&self) {
350        if let Some(hook) = &self.on_handoff {
351            hook();
352        }
353    }
354
355    fn connection_closed(&self, conn: u64) {
356        if let Some(hook) = &self.on_connection_closed {
357            hook(conn);
358        }
359    }
360}