Skip to main content

paykit_lib/
app_registry.rs

1//! Identity-wide Paykit application registry.
2
3use std::{
4    collections::{BTreeMap, HashMap},
5    fmt,
6};
7
8use serde::{de, Deserialize, Serialize};
9
10use crate::{
11    error::map_error, pubky_routing, validation::invalid_data, PaykitAppId, PaykitError,
12    PaymentEndpointIdentifier, PublicKey, Result,
13};
14
15const APP_REGISTRY_KIND: &str = "paykit.app_registry";
16const APP_REGISTRY_VERSION: u8 = 1;
17const APP_DISPLAY_NAME_MAX_LEN: usize = 128;
18/// Initial generation for identity-wide Paykit key material.
19pub const INITIAL_PAYKIT_KEY_GENERATION: u64 = 1;
20/// Maximum serialized Paykit App Registry size accepted from public storage.
21pub const PAYKIT_APP_REGISTRY_MAX_BYTES: usize = 64 * 1024;
22/// Maximum number of applications in one Paykit App Registry.
23pub const PAYKIT_APP_REGISTRY_MAX_APPS: usize = 64;
24/// Maximum number of endpoint-specific application defaults in one registry.
25pub const PAYKIT_APP_REGISTRY_MAX_ENDPOINT_DEFAULTS: usize = 256;
26
27/// Capabilities advertised by one application in a Paykit identity.
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct PaykitAppCapabilities {
31    /// App can publish or consume private Payment Lists.
32    pub private_payments: bool,
33    /// App can create or handle Payment Requests.
34    pub payment_requests: bool,
35    /// App can create or retrieve Receipts.
36    pub receipts: bool,
37    /// App advertises that it can execute outgoing payments.
38    ///
39    /// The SDK requires this capability before an App can claim or accept a
40    /// Payment Request for execution.
41    pub outgoing_payments: bool,
42}
43
44/// Public description of one application using a Paykit identity.
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(try_from = "PaykitAppWire", into = "PaykitAppWire")]
47pub struct PaykitApp {
48    display_name: String,
49    capabilities: PaykitAppCapabilities,
50}
51
52impl PaykitApp {
53    /// Create a validated Paykit app description.
54    pub fn new(
55        display_name: impl Into<String>,
56        capabilities: PaykitAppCapabilities,
57    ) -> Result<Self> {
58        let display_name = display_name.into();
59        validate_display_name(&display_name)?;
60        Ok(Self {
61            display_name,
62            capabilities,
63        })
64    }
65
66    /// Return the user-readable application name.
67    pub fn display_name(&self) -> &str {
68        &self.display_name
69    }
70
71    /// Return the application's Paykit capabilities.
72    pub fn capabilities(&self) -> PaykitAppCapabilities {
73        self.capabilities
74    }
75}
76
77/// Public registry shared by every application using one Paykit identity.
78#[derive(Clone, PartialEq, Eq)]
79pub struct PaykitAppRegistry {
80    key_generation: u64,
81    noise_public_key: Option<PublicKey>,
82    apps: HashMap<PaykitAppId, PaykitApp>,
83    default_app_id: Option<PaykitAppId>,
84    default_apps_by_endpoint: HashMap<PaymentEndpointIdentifier, PaykitAppId>,
85}
86
87impl fmt::Debug for PaykitAppRegistry {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.debug_struct("PaykitAppRegistry")
90            .field("key_generation", &self.key_generation)
91            .field("noise_public_key", &self.noise_public_key)
92            .field("apps", &self.apps)
93            .field("default_app_id", &self.default_app_id)
94            .field("default_apps_by_endpoint", &self.default_apps_by_endpoint)
95            .finish()
96    }
97}
98
99impl PaykitAppRegistry {
100    /// Create an empty registry with an optional identity-wide Noise public key.
101    ///
102    /// Public-only identities can publish applications and Payment Endpoints
103    /// before a private-capable application initializes the Noise key.
104    pub fn new(noise_public_key: Option<PublicKey>) -> Self {
105        Self {
106            key_generation: INITIAL_PAYKIT_KEY_GENERATION,
107            noise_public_key,
108            apps: HashMap::new(),
109            default_app_id: None,
110            default_apps_by_endpoint: HashMap::new(),
111        }
112    }
113
114    /// Return the advertised generation; verify the signed Noise key authorization before use.
115    pub fn key_generation(&self) -> u64 {
116        self.key_generation
117    }
118
119    /// Return the advertised Noise public key, when initialized.
120    /// This is discovery metadata, not identity-signed authorization.
121    pub fn noise_public_key(&self) -> Option<&PublicKey> {
122        self.noise_public_key.as_ref()
123    }
124
125    /// Initialize or verify the identity-wide Noise public key and its generation.
126    pub fn set_noise_public_key(
127        &mut self,
128        noise_public_key: PublicKey,
129        key_generation: u64,
130    ) -> Result<()> {
131        if key_generation == 0 {
132            return Err(PaykitError::Validation(
133                "Paykit key generation must be positive".into(),
134            ));
135        }
136        if self.noise_public_key.as_ref().is_some_and(|existing| {
137            existing != &noise_public_key || self.key_generation != key_generation
138        }) {
139            return Err(PaykitError::Validation(
140                "Paykit App Registry Noise public key is already initialized to a different key"
141                    .into(),
142            ));
143        }
144        self.noise_public_key = Some(noise_public_key);
145        self.key_generation = key_generation;
146        Ok(())
147    }
148
149    /// Rotate the identity-wide Noise public key to the next key generation.
150    pub fn rotate_noise_public_key(
151        &mut self,
152        noise_public_key: PublicKey,
153        key_generation: u64,
154    ) -> Result<()> {
155        let expected_generation = self.key_generation.checked_add(1).ok_or_else(|| {
156            PaykitError::Validation("Paykit App Registry key generation is exhausted".into())
157        })?;
158        if key_generation != expected_generation {
159            return Err(PaykitError::Validation(format!(
160                "Paykit App Registry key generation must advance from {} to {expected_generation}",
161                self.key_generation
162            )));
163        }
164        self.noise_public_key = Some(noise_public_key);
165        self.key_generation = key_generation;
166        Ok(())
167    }
168
169    /// Return all registered applications keyed by App ID.
170    pub fn apps(&self) -> &HashMap<PaykitAppId, PaykitApp> {
171        &self.apps
172    }
173
174    /// Return the identity-wide default App ID.
175    pub fn default_app_id(&self) -> Option<&PaykitAppId> {
176        self.default_app_id.as_ref()
177    }
178
179    /// Return per-endpoint default applications.
180    pub fn default_apps_by_endpoint(&self) -> &HashMap<PaymentEndpointIdentifier, PaykitAppId> {
181        &self.default_apps_by_endpoint
182    }
183
184    /// Return the preferred application for one Payment Endpoint Identifier.
185    ///
186    /// An endpoint-specific default takes precedence over the identity-wide
187    /// default application.
188    pub fn preferred_app_for_endpoint(
189        &self,
190        identifier: &PaymentEndpointIdentifier,
191    ) -> Option<&PaykitAppId> {
192        self.default_apps_by_endpoint
193            .get(identifier)
194            .or(self.default_app_id.as_ref())
195    }
196
197    /// Add or replace one application registration.
198    pub fn register_app(&mut self, app_id: PaykitAppId, app: PaykitApp) -> Result<()> {
199        if self.noise_public_key.is_none() && app_uses_private_protocol(app.capabilities()) {
200            return Err(PaykitError::Validation(
201                "Paykit App Registry must initialize its Noise public key before registering private capabilities"
202                    .into(),
203            ));
204        }
205        if !self.apps.contains_key(&app_id) && self.apps.len() >= PAYKIT_APP_REGISTRY_MAX_APPS {
206            return Err(PaykitError::Validation(format!(
207                "Paykit App Registry must not contain more than {PAYKIT_APP_REGISTRY_MAX_APPS} applications"
208            )));
209        }
210        self.apps.insert(app_id, app);
211        Ok(())
212    }
213
214    /// Remove an application and defaults that refer to it.
215    pub fn remove_app(&mut self, app_id: &PaykitAppId) -> Option<PaykitApp> {
216        let removed = self.apps.remove(app_id)?;
217        if self.default_app_id.as_ref() == Some(app_id) {
218            self.default_app_id = None;
219        }
220        self.default_apps_by_endpoint
221            .retain(|_, default_app_id| default_app_id != app_id);
222        Some(removed)
223    }
224
225    /// Set or clear the identity-wide default application.
226    pub fn set_default_app(&mut self, app_id: Option<PaykitAppId>) -> Result<()> {
227        if let Some(app_id) = &app_id {
228            self.require_registered_app(app_id)?;
229        }
230        self.default_app_id = app_id;
231        Ok(())
232    }
233
234    /// Set the default application for one Payment Endpoint Identifier.
235    pub fn set_default_app_for_endpoint(
236        &mut self,
237        identifier: PaymentEndpointIdentifier,
238        app_id: PaykitAppId,
239    ) -> Result<()> {
240        self.require_registered_app(&app_id)?;
241        if !self.default_apps_by_endpoint.contains_key(&identifier)
242            && self.default_apps_by_endpoint.len() >= PAYKIT_APP_REGISTRY_MAX_ENDPOINT_DEFAULTS
243        {
244            return Err(PaykitError::Validation(format!(
245                "Paykit App Registry must not contain more than {PAYKIT_APP_REGISTRY_MAX_ENDPOINT_DEFAULTS} endpoint defaults"
246            )));
247        }
248        self.default_apps_by_endpoint.insert(identifier, app_id);
249        Ok(())
250    }
251
252    /// Clear the default application for one Payment Endpoint Identifier.
253    pub fn clear_default_app_for_endpoint(
254        &mut self,
255        identifier: &PaymentEndpointIdentifier,
256    ) -> Option<PaykitAppId> {
257        self.default_apps_by_endpoint.remove(identifier)
258    }
259
260    fn require_registered_app(&self, app_id: &PaykitAppId) -> Result<()> {
261        if self.apps.contains_key(app_id) {
262            return Ok(());
263        }
264        Err(PaykitError::Validation(format!(
265            "Paykit App ID '{app_id}' is not registered"
266        )))
267    }
268}
269
270#[derive(Clone, Serialize, Deserialize)]
271#[serde(deny_unknown_fields)]
272struct PaykitAppWire {
273    display_name: String,
274    capabilities: PaykitAppCapabilities,
275}
276
277impl TryFrom<PaykitAppWire> for PaykitApp {
278    type Error = PaykitError;
279
280    fn try_from(wire: PaykitAppWire) -> Result<Self> {
281        Self::new(wire.display_name, wire.capabilities)
282    }
283}
284
285impl From<PaykitApp> for PaykitAppWire {
286    fn from(app: PaykitApp) -> Self {
287        Self {
288            display_name: app.display_name,
289            capabilities: app.capabilities,
290        }
291    }
292}
293
294#[derive(Deserialize)]
295#[serde(deny_unknown_fields)]
296struct AppRegistryWire {
297    version: u8,
298    kind: String,
299    key_generation: u64,
300    noise_public_key: Option<String>,
301    #[serde(deserialize_with = "deserialize_unique_map")]
302    apps: HashMap<String, PaykitAppWire>,
303    default_app_id: Option<String>,
304    #[serde(deserialize_with = "deserialize_unique_map")]
305    default_apps_by_endpoint: HashMap<String, String>,
306}
307
308#[derive(Serialize)]
309struct AppRegistryWireRef<'a> {
310    version: u8,
311    kind: &'static str,
312    key_generation: u64,
313    noise_public_key: Option<String>,
314    apps: BTreeMap<&'a str, &'a PaykitApp>,
315    default_app_id: Option<&'a str>,
316    default_apps_by_endpoint: BTreeMap<&'a str, &'a str>,
317}
318
319/// Serialize an identity-wide Paykit App Registry.
320pub fn serialize_paykit_app_registry(registry: &PaykitAppRegistry) -> Result<String> {
321    validate_registry_limits(registry).map_err(PaykitError::Validation)?;
322    let apps = registry
323        .apps
324        .iter()
325        .map(|(app_id, app)| (app_id.as_str(), app))
326        .collect();
327    let default_apps_by_endpoint = registry
328        .default_apps_by_endpoint
329        .iter()
330        .map(|(identifier, app_id)| (identifier.as_str(), app_id.as_str()))
331        .collect();
332    let raw_json = serde_json::to_string(&AppRegistryWireRef {
333        version: APP_REGISTRY_VERSION,
334        kind: APP_REGISTRY_KIND,
335        key_generation: registry.key_generation,
336        noise_public_key: registry.noise_public_key.as_ref().map(PublicKey::z32),
337        apps,
338        default_app_id: registry.default_app_id.as_ref().map(PaykitAppId::as_str),
339        default_apps_by_endpoint,
340    })
341    .map_err(|err| {
342        PaykitError::Validation(format!("failed to serialize Paykit App Registry: {err}"))
343    })?;
344    if raw_json.len() > PAYKIT_APP_REGISTRY_MAX_BYTES {
345        return Err(PaykitError::Validation(format!(
346            "Paykit App Registry must not exceed {PAYKIT_APP_REGISTRY_MAX_BYTES} bytes"
347        )));
348    }
349    Ok(raw_json)
350}
351
352/// Parse and validate an identity-wide Paykit App Registry.
353pub fn parse_paykit_app_registry_json(raw_json: &str) -> Result<PaykitAppRegistry> {
354    // SECURITY / REDACTION: the registry is any identity's public data, and
355    // payment resolution carries these error contexts into FFI exception text.
356    // Every context on this path is a static label. Serde errors quote the JSON
357    // and identifier validators may echo the value, so those causes are
358    // dropped; the causes kept below have static Displays.
359    if raw_json.len() > PAYKIT_APP_REGISTRY_MAX_BYTES {
360        return Err(invalid_data(
361            format!("Paykit App Registry exceeds {PAYKIT_APP_REGISTRY_MAX_BYTES} bytes"),
362            None,
363        ));
364    }
365    let wire = serde_json::from_str::<AppRegistryWire>(raw_json)
366        .map_err(|_| invalid_data("Paykit App Registry JSON is invalid", None))?;
367    if wire.version != APP_REGISTRY_VERSION || wire.kind != APP_REGISTRY_KIND {
368        return Err(invalid_data(
369            "unsupported Paykit App Registry version/kind",
370            None,
371        ));
372    }
373    if wire.key_generation == 0 {
374        return Err(invalid_data(
375            "Paykit App Registry key generation must be greater than zero",
376            None,
377        ));
378    }
379    if wire.apps.len() > PAYKIT_APP_REGISTRY_MAX_APPS {
380        return Err(invalid_data(
381            format!(
382                "Paykit App Registry contains more than {PAYKIT_APP_REGISTRY_MAX_APPS} applications"
383            ),
384            None,
385        ));
386    }
387    if wire.default_apps_by_endpoint.len() > PAYKIT_APP_REGISTRY_MAX_ENDPOINT_DEFAULTS {
388        return Err(invalid_data(
389            format!(
390                "Paykit App Registry contains more than {PAYKIT_APP_REGISTRY_MAX_ENDPOINT_DEFAULTS} endpoint defaults"
391            ),
392            None,
393        ));
394    }
395
396    let noise_public_key = wire
397        .noise_public_key
398        .map(|value| {
399            PublicKey::try_from_z32(&value).map_err(|err| {
400                invalid_data(
401                    format!("Paykit App Registry Noise public key is invalid: {err}"),
402                    Some(err.into()),
403                )
404            })
405        })
406        .transpose()?;
407    let mut registry = PaykitAppRegistry::new(noise_public_key);
408    registry.key_generation = wire.key_generation;
409    for (raw_app_id, raw_app) in wire.apps {
410        let app_id = parse_remote_app_id(raw_app_id)?;
411        let app = PaykitApp::try_from(raw_app).map_err(|err| {
412            invalid_data(
413                format!("Paykit App Registry contains an invalid app: {err}"),
414                Some(err.into()),
415            )
416        })?;
417        registry.register_app(app_id, app).map_err(|err| {
418            invalid_data(
419                format!("Paykit App Registry contains an invalid application: {err}"),
420                Some(err.into()),
421            )
422        })?;
423    }
424
425    registry.default_app_id = wire.default_app_id.map(parse_remote_app_id).transpose()?;
426    for (raw_identifier, raw_app_id) in wire.default_apps_by_endpoint {
427        let identifier = PaymentEndpointIdentifier::new(raw_identifier).map_err(|_| {
428            invalid_data(
429                "Paykit App Registry contains an invalid Payment Endpoint Identifier",
430                None,
431            )
432        })?;
433        registry
434            .default_apps_by_endpoint
435            .insert(identifier, parse_remote_app_id(raw_app_id)?);
436    }
437    validate_registry_defaults(&registry)?;
438    Ok(registry)
439}
440
441/// Create the identity-wide Paykit App Registry if it does not exist.
442///
443/// The caller is responsible for creating a Pubky session with write access to
444/// the registry path and for session lifetime, capability scope, and key
445/// rotation. Request timeouts are configured on the Pubky client; Paykit does
446/// not impose an additional deadline. Concurrent creation fails rather than
447/// replacing the existing registry.
448///
449/// # Errors
450///
451/// Returns [`PaykitError::Validation`] when the registry cannot be serialized
452/// and [`PaykitError::Transport`] when Pubky storage rejects the write.
453pub async fn create_paykit_app_registry(
454    session: &pubky::PubkySession,
455    registry: &PaykitAppRegistry,
456) -> Result<()> {
457    pubky_routing::create_paykit_app_registry(session, registry)
458        .await
459        .map_err(|err| map_error("create_paykit_app_registry", err))
460}
461
462/// Replace the identity-wide Paykit App Registry if its content still matches.
463///
464/// `revision` must come from the same response as the registry being modified.
465/// The content is checked under a WebDAV lock. A concurrent change fails so
466/// the caller can refetch, merge its mutation, and retry.
467/// The caller remains responsible for Pubky session lifetime, capability scope,
468/// key rotation, and request timeout configuration.
469///
470/// # Errors
471///
472/// Returns [`PaykitError::Validation`] when the registry cannot be serialized
473/// and [`PaykitError::Transport`] when the revision is stale or Pubky rejects the
474/// write.
475pub async fn update_paykit_app_registry(
476    session: &pubky::PubkySession,
477    registry: &PaykitAppRegistry,
478    revision: &str,
479) -> Result<()> {
480    pubky_routing::update_paykit_app_registry(session, registry, revision)
481        .await
482        .map_err(|err| map_error("update_paykit_app_registry", err))
483}
484
485/// Fetch the identity-wide Paykit App Registry, if it exists.
486///
487/// This unauthenticated read returns `Ok(None)` when the registry is missing.
488/// An empty file is malformed data. The caller chooses the Pubky client, owner
489/// identity, timeout policy, and key-rotation strategy; Paykit only reads the
490/// registry path.
491///
492/// # Errors
493///
494/// Returns [`PaykitError::InvalidData`] for malformed remote data and
495/// [`PaykitError::Transport`] when Pubky storage cannot be read.
496pub async fn get_paykit_app_registry(
497    storage: &pubky::PublicStorage,
498    owner: &PublicKey,
499) -> Result<Option<PaykitAppRegistry>> {
500    pubky_routing::fetch_paykit_app_registry(storage, owner)
501        .await
502        .map_err(|err| map_error("get_paykit_app_registry", err))
503}
504
505/// Fetch the identity-wide Paykit App Registry and its content revision.
506///
507/// The revision hashes the same bytes as the returned registry and can be
508/// passed to [`update_paykit_app_registry`].
509///
510/// # Errors
511///
512/// Returns [`PaykitError::InvalidData`] for malformed data and
513/// [`PaykitError::Transport`] when Pubky storage cannot be read.
514pub async fn get_paykit_app_registry_with_revision(
515    storage: &pubky::PublicStorage,
516    owner: &PublicKey,
517) -> Result<Option<(PaykitAppRegistry, String)>> {
518    pubky_routing::fetch_paykit_app_registry_with_revision(storage, owner)
519        .await
520        .map_err(|err| map_error("get_paykit_app_registry_with_revision", err))
521}
522
523fn parse_remote_app_id(value: String) -> Result<PaykitAppId> {
524    PaykitAppId::new(value)
525        .map_err(|_| invalid_data("Paykit App Registry contains an invalid App ID", None))
526}
527
528fn validate_registry_defaults(registry: &PaykitAppRegistry) -> Result<()> {
529    if let Some(app_id) = registry.default_app_id() {
530        validate_registered_default(registry, app_id, "identity default")?;
531    }
532    for app_id in registry.default_apps_by_endpoint().values() {
533        validate_registered_default(registry, app_id, "endpoint default")?;
534    }
535    Ok(())
536}
537
538fn validate_registry_limits(registry: &PaykitAppRegistry) -> std::result::Result<(), String> {
539    if registry.apps.len() > PAYKIT_APP_REGISTRY_MAX_APPS {
540        return Err(format!(
541            "Paykit App Registry must not contain more than {PAYKIT_APP_REGISTRY_MAX_APPS} applications"
542        ));
543    }
544    if registry.default_apps_by_endpoint.len() > PAYKIT_APP_REGISTRY_MAX_ENDPOINT_DEFAULTS {
545        return Err(format!(
546            "Paykit App Registry must not contain more than {PAYKIT_APP_REGISTRY_MAX_ENDPOINT_DEFAULTS} endpoint defaults"
547        ));
548    }
549    if registry.noise_public_key.is_none()
550        && registry
551            .apps
552            .values()
553            .any(|app| app_uses_private_protocol(app.capabilities()))
554    {
555        return Err(
556            "Paykit App Registry must initialize its Noise public key before advertising private capabilities"
557                .into(),
558        );
559    }
560    Ok(())
561}
562
563fn app_uses_private_protocol(capabilities: PaykitAppCapabilities) -> bool {
564    capabilities.private_payments || capabilities.payment_requests || capabilities.receipts
565}
566
567fn validate_registered_default(
568    registry: &PaykitAppRegistry,
569    app_id: &PaykitAppId,
570    label: &'static str,
571) -> Result<()> {
572    if registry.apps.contains_key(app_id) {
573        return Ok(());
574    }
575    Err(invalid_data(
576        format!("Paykit App Registry {label} refers to an unregistered App ID"),
577        None,
578    ))
579}
580
581fn validate_display_name(value: &str) -> Result<()> {
582    if value.trim().is_empty() {
583        return Err(PaykitError::Validation(
584            "Paykit app display name must not be empty".into(),
585        ));
586    }
587    if value.len() > APP_DISPLAY_NAME_MAX_LEN {
588        return Err(PaykitError::Validation(format!(
589            "Paykit app display name must not exceed {APP_DISPLAY_NAME_MAX_LEN} bytes"
590        )));
591    }
592    if value.chars().any(char::is_control) {
593        return Err(PaykitError::Validation(
594            "Paykit app display name must not contain control characters".into(),
595        ));
596    }
597    Ok(())
598}
599
600fn deserialize_unique_map<'de, D, V>(
601    deserializer: D,
602) -> std::result::Result<HashMap<String, V>, D::Error>
603where
604    D: de::Deserializer<'de>,
605    V: Deserialize<'de>,
606{
607    struct UniqueMapVisitor<V>(std::marker::PhantomData<V>);
608
609    impl<'de, V> de::Visitor<'de> for UniqueMapVisitor<V>
610    where
611        V: Deserialize<'de>,
612    {
613        type Value = HashMap<String, V>;
614
615        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
616            formatter.write_str("an object without duplicate keys")
617        }
618
619        fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
620        where
621            A: de::MapAccess<'de>,
622        {
623            let mut values = HashMap::new();
624            while let Some((key, value)) = map.next_entry::<String, V>()? {
625                if values.insert(key.clone(), value).is_some() {
626                    return Err(de::Error::custom(format!("duplicate key '{key}'")));
627                }
628            }
629            Ok(values)
630        }
631    }
632
633    deserializer.deserialize_map(UniqueMapVisitor(std::marker::PhantomData))
634}
635
636#[cfg(test)]
637mod tests;