Skip to main content

ably_chat/
capability.rs

1//! Typed Ably capability model (ADR-0012, SPEC §13.1). Feature `capabilities`.
2
3/// An Ably capability operation. `#[non_exhaustive]`; unknown wire values map to
4/// `Other` (ADR-0007) so parsing never fails.
5#[derive(Debug, Clone, PartialEq, Eq)]
6#[non_exhaustive]
7pub enum Operation {
8    Subscribe,
9    Publish,
10    Presence,
11    ObjectSubscribe,
12    ObjectPublish,
13    AnnotationSubscribe,
14    AnnotationPublish,
15    MessageUpdateOwn,
16    MessageUpdateAny,
17    MessageDeleteOwn,
18    MessageDeleteAny,
19    History,
20    Stats,
21    PushSubscribe,
22    PushAdmin,
23    ChannelMetadata,
24    PrivilegedHeaders,
25    /// A forward-compatible/custom operation string.
26    Other(String),
27}
28
29impl Operation {
30    /// The exact wire string Ably uses for this operation.
31    pub fn as_str(&self) -> &str {
32        match self {
33            Operation::Subscribe => "subscribe",
34            Operation::Publish => "publish",
35            Operation::Presence => "presence",
36            Operation::ObjectSubscribe => "object-subscribe",
37            Operation::ObjectPublish => "object-publish",
38            Operation::AnnotationSubscribe => "annotation-subscribe",
39            Operation::AnnotationPublish => "annotation-publish",
40            Operation::MessageUpdateOwn => "message-update-own",
41            Operation::MessageUpdateAny => "message-update-any",
42            Operation::MessageDeleteOwn => "message-delete-own",
43            Operation::MessageDeleteAny => "message-delete-any",
44            Operation::History => "history",
45            Operation::Stats => "stats",
46            Operation::PushSubscribe => "push-subscribe",
47            Operation::PushAdmin => "push-admin",
48            Operation::ChannelMetadata => "channel-metadata",
49            Operation::PrivilegedHeaders => "privileged-headers",
50            Operation::Other(s) => s.as_str(),
51        }
52    }
53}
54
55/// A capability document: resource pattern → set of allowed operation strings.
56/// Operation strings are stored (not the enum) so the `BTreeSet` sorts
57/// lexicographically by wire value, matching Ably's canonicalization.
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
59pub struct Capability(std::collections::BTreeMap<String, std::collections::BTreeSet<String>>);
60
61impl Capability {
62    /// An empty capability document.
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Grant `ops` on `resource` (a channel-name pattern, e.g. `"room"`,
68    /// `"dms:*"`, `"*"`). Repeated resources merge.
69    pub fn allow(
70        mut self,
71        resource: impl Into<String>,
72        ops: impl IntoIterator<Item = Operation>,
73    ) -> Self {
74        let entry = self.0.entry(resource.into()).or_default();
75        for op in ops {
76            entry.insert(op.as_str().to_owned());
77        }
78        self
79    }
80
81    /// The canonical capability string for a TokenRequest or an
82    /// `x-ably-capability` JWT claim: sorted resource keys, sorted operations,
83    /// no whitespace.
84    pub fn to_capability_string(&self) -> String {
85        // Infallible: the map is String→[String].
86        serde_json::to_string(&self.0).expect("capability map is always serializable")
87    }
88
89    /// Grant `ops` on a chat room, scoped by **room name**.
90    ///
91    /// Uses the bare room name, which Ably's product model expands to authorize
92    /// both the `/chat/v4` REST API and the `room::$chat` channel. Do **not** pass
93    /// `"{room}::$chat"` here — that authorizes only the realtime channel and would
94    /// `40160` on REST. The explicit product qualifier `[chat]{room}` is available
95    /// via [`Capability::allow`].
96    ///
97    /// NOTE (pre-1.0): the exact resource form is confirmed only to moderate-high
98    /// confidence; verify against a live app before stabilization (see
99    /// `docs/research/2026-07-24-ably-chat-auth-permissions.md` §A2.2).
100    pub fn for_room(self, room: &str, ops: impl IntoIterator<Item = Operation>) -> Self {
101        self.allow(room.to_owned(), ops)
102    }
103
104    /// The capability as a native JSON object (not a stringified value), for the
105    /// Ably Control API key `capability` field (ADR-0012 §A2.5).
106    pub fn to_json(&self) -> serde_json::Value {
107        serde_json::to_value(&self.0).expect("capability map is always serializable")
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn operation_as_str_matches_ably_strings() {
117        assert_eq!(Operation::Publish.as_str(), "publish");
118        assert_eq!(Operation::Subscribe.as_str(), "subscribe");
119        assert_eq!(Operation::ObjectSubscribe.as_str(), "object-subscribe");
120        assert_eq!(Operation::AnnotationPublish.as_str(), "annotation-publish");
121        assert_eq!(Operation::MessageUpdateOwn.as_str(), "message-update-own");
122        assert_eq!(Operation::MessageDeleteAny.as_str(), "message-delete-any");
123        assert_eq!(Operation::ChannelMetadata.as_str(), "channel-metadata");
124        assert_eq!(Operation::PrivilegedHeaders.as_str(), "privileged-headers");
125        assert_eq!(Operation::Other("custom".into()).as_str(), "custom");
126    }
127
128    #[test]
129    fn canonical_string_sorts_keys_and_ops_no_whitespace() {
130        let cap = Capability::new()
131            .allow("z-room", [Operation::Subscribe, Operation::Publish])
132            .allow("a-room", [Operation::History]);
133        // Keys sorted (a-room before z-room); ops sorted (publish before subscribe); no spaces.
134        assert_eq!(
135            cap.to_capability_string(),
136            r#"{"a-room":["history"],"z-room":["publish","subscribe"]}"#
137        );
138    }
139
140    #[test]
141    fn allow_merges_repeated_resource() {
142        let cap = Capability::new()
143            .allow("r", [Operation::Publish])
144            .allow("r", [Operation::History]);
145        assert_eq!(cap.to_capability_string(), r#"{"r":["history","publish"]}"#);
146    }
147
148    #[test]
149    fn for_room_scopes_bare_room_name() {
150        // Bare room name (Ably's documented form). NOT "sports::$chat".
151        let cap = Capability::new().for_room("sports", [Operation::Publish, Operation::History]);
152        assert_eq!(
153            cap.to_capability_string(),
154            r#"{"sports":["history","publish"]}"#
155        );
156    }
157
158    #[test]
159    fn to_json_returns_native_object() {
160        let cap = Capability::new().allow("r", [Operation::Publish, Operation::History]);
161        let v = cap.to_json();
162        assert!(v.is_object());
163        assert_eq!(v["r"], serde_json::json!(["history", "publish"]));
164    }
165}