Skip to main content

watchword_core/
musliweb05.rs

1//! watchword's account and user-management operations, and its live updates,
2//! as [musli-web] 0.5 API types, so an app can carry them on the musli-web
3//! WebSocket it already has.
4//!
5//! The endpoints and [`AuthBroadcast`] form a `define!` list of their own.
6//! watchword pins each of its types to an id in 32000–32255 ([`FIRST_ID`] to
7//! [`LAST_ID`]), and a released id never changes meaning. The band is a
8//! convention, not something musli-web enforces: an app's own implicit ids
9//! count up from 1 and never reach it, but an app that pins ids of its own
10//! with `#[musli(id = ..)]` can land in it. How such a clash is caught
11//! depends on how the app layers watchword's list on its socket, below.
12//!
13//! A response is the operation's value. A refused request fails with the same
14//! structured [`AuthError`] as over HTTP, sent as musli-web's typed request
15//! error, which the client decodes with `web::Error::server_error`; an
16//! internal failure fails with just the message `internal error`. Each request
17//! body is its own type here, since musli-web ties a request type to one
18//! endpoint.
19//!
20//! # Including watchword's list
21//!
22//! The main way is to include watchword's list in the app's own, with
23//! `include watchword_core::musliweb05 as Auth;` (or `watchword::musliweb05`
24//! from a crate that already depends on `watchword`):
25//!
26//! - The client gets watchword's endpoints through the app's own list type,
27//!   so one socket handle sends both, and watchword-yew023's components
28//!   follow [`AuthBroadcast`] on that same socket.
29//! - The app's `Request` gets an `Auth(musliweb05::Request)` variant, so its
30//!   handler's exhaustive `match` does not compile until it forwards that
31//!   variant (to `watchword::musliweb05::Connection::handle`).
32//! - musli-web checks that no id of the app's list is also one of the
33//!   included list's in plain `const` items, so a clash fails `cargo check`.
34//!
35//! ```
36//! use musli_core02::{Decode, Encode};
37//! use musli_web05::api::Id;
38//! use watchword_core::musliweb05::{self as auth, Me};
39//!
40//! #[derive(Encode, Decode)]
41//! #[musli(crate = musli_core02)]
42//! pub struct StatusRequest;
43//!
44//! musli_web05::api::define! {
45//!     // Only needed since the dependency is renamed, as it is here.
46//!     #![musli(crate = musli_web05)]
47//!
48//!     pub type Status;
49//!
50//!     impl Endpoint for Status {
51//!         impl Request for StatusRequest;
52//!         type Response<'de> = u32;
53//!     }
54//!
55//!     /// watchword's endpoints and broadcast.
56//!     include watchword_core::musliweb05 as Auth;
57//! }
58//!
59//! assert_eq!(Request::from_id(Me::ID), Request::Auth(auth::Request::Me));
60//! assert_eq!(Request::from_id(Status::ID), Request::Status);
61//! ```
62//!
63//! # Without including it
64//!
65//! An app whose API crate should not depend on watchword's types keeps the
66//! two lists apart instead, and on the server combines its handler with
67//! watchword's through musli-web's `ws::Handler::or`, which routes each
68//! request to the handler whose list knows its id; the client then sends
69//! watchword's requests by their own types. `Or` refuses two lists that share
70//! an id, but since it is generic that check is only evaluated when the code
71//! constructing it is compiled into a binary, so `cargo check` misses it.
72//! Assert in a `const` item, where the two lists meet, that their [`IdSet`]s
73//! are disjoint, and a clash fails `cargo check` too:
74//!
75//! ```
76//! use musli_web05::api::Id;
77//! use watchword_core::musliweb05 as auth;
78//!
79//! mod api {
80//!     use musli_core02::{Decode, Encode};
81//!
82//!     #[derive(Encode, Decode)]
83//!     #[musli(crate = musli_core02)]
84//!     pub struct StatusRequest;
85//!
86//!     musli_web05::api::define! {
87//!         #![musli(crate = musli_web05)]
88//!
89//!         pub type Status;
90//!
91//!         impl Endpoint for Status {
92//!             impl Request for StatusRequest;
93//!             type Response<'de> = u32;
94//!         }
95//!     }
96//! }
97//!
98//! // The app's ids and watchword's never meet.
99//! const _: () = {
100//!     assert!(api::Request::IDS.is_disjoint(&auth::Request::IDS));
101//!     assert!(api::Event::IDS.is_disjoint(&auth::Event::IDS));
102//! };
103//! ```
104//!
105//! [`IdSet`]: musli_web05::api::IdSet
106//!
107//! # Endpoints
108//!
109//! | Endpoint | Id | Request | Response |
110//! |---|---|---|---|
111//! | [`Me`] | 32000 | [`MeRequest`] | [`UserInfo`] |
112//! | [`UpdateAccount`] | 32001 | [`UserChange`] | [`UserInfo`] |
113//! | [`ChangePassword`] | 32002 | [`PasswordChange`] | [`UserInfo`] |
114//! | [`Users`] | 32003 | [`UsersRequest`] | `Vec<`[`UserEntry`]`>` |
115//! | [`CreateUser`] | 32004 | [`NewUser`] | [`CreatedUser`] |
116//! | [`UpdateUser`] | 32005 | [`UserUpdate`] | [`UserInfo`] |
117//! | [`DeleteUser`] | 32006 | [`DeleteUserRequest`] | `()` |
118//! | [`IssueLink`] | 32007 | [`IssueLinkRequest`] | [`Link`] |
119//! | [`RevokeLink`] | 32008 | [`RevokeLinkRequest`] | `()` |
120//! | [`SignOutUser`] | 32009 | [`SignOutUserRequest`] | `()` |
121//! | [`RevokeAccess`] | 32010 | [`RevokeAccessRequest`] | [`UserInfo`] |
122//! | [`Cloudflare`] | 32011 | [`CloudflareRequest`] | [`CloudflareConfig`] |
123//! | [`SetCloudflare`] | 32012 | [`CloudflareConfig`] | [`CloudflareConfig`] |
124//!
125//! [`AuthBroadcast`] (32100) carries [`AuthUpdate`]s.
126//!
127//! [musli-web]: https://docs.rs/musli-web
128
129use alloc::vec::Vec;
130
131use musli_core02::{Decode, Encode};
132
133#[cfg(doc)]
134use crate::AuthError;
135use crate::{
136    CloudflareConfig, CreatedUser, Link, NewUser, PasswordChange, UserChange, UserEntry, UserId,
137    UserInfo,
138};
139
140/// The first id of the band watchword owns.
141///
142/// The band is a convention: an app that pins its own ids can land in it. See
143/// the [module docs](self) for how a clash is caught.
144pub const FIRST_ID: u16 = 32000;
145
146/// The last id of the band watchword owns.
147pub const LAST_ID: u16 = 32255;
148
149/// Whether `id` is in the band of ids watchword owns, [`FIRST_ID`] to
150/// [`LAST_ID`].
151pub fn is_watchword_id(id: musli_web05::api::MessageId) -> bool {
152    (FIRST_ID..=LAST_ID).contains(&id.get())
153}
154
155musli_web05::api::define! {
156    #![musli(crate = musli_web05)]
157
158    /// The signed-in user, with [`UserInfo::impersonated_by`] set when an
159    /// admin is acting as them. Like `GET /api/auth/me`.
160    #[musli(id = 32000)]
161    pub type Me;
162
163    impl Endpoint for Me {
164        impl Request for MeRequest;
165        type Response<'de> = UserInfo;
166    }
167
168    /// Changes the signed-in user's own login or email. Like
169    /// `PATCH /api/auth/account`.
170    #[musli(id = 32001)]
171    pub type UpdateAccount;
172
173    impl Endpoint for UpdateAccount {
174        impl Request for UserChange;
175        type Response<'de> = UserInfo;
176    }
177
178    /// Sets or changes the signed-in user's password. Like
179    /// `POST /api/auth/account/password`.
180    #[musli(id = 32002)]
181    pub type ChangePassword;
182
183    impl Endpoint for ChangePassword {
184        impl Request for PasswordChange;
185        type Response<'de> = UserInfo;
186    }
187
188    /// Every user, for the admin list. Admins only. Like
189    /// `GET /api/auth/users`.
190    #[musli(id = 32003)]
191    pub type Users;
192
193    impl Endpoint for Users {
194        impl Request for UsersRequest;
195        type Response<'de> = Vec<UserEntry>;
196    }
197
198    /// Creates a user and their registration link. Admins only. Like
199    /// `POST /api/auth/users`.
200    #[musli(id = 32004)]
201    pub type CreateUser;
202
203    impl Endpoint for CreateUser {
204        impl Request for NewUser;
205        type Response<'de> = CreatedUser;
206    }
207
208    /// Changes another user. Admins only. Like
209    /// `PATCH /api/auth/users/{id}`.
210    #[musli(id = 32005)]
211    pub type UpdateUser;
212
213    impl Endpoint for UpdateUser {
214        impl Request for UserUpdate;
215        type Response<'de> = UserInfo;
216    }
217
218    /// Deletes another user. Admins only. Like
219    /// `DELETE /api/auth/users/{id}`.
220    #[musli(id = 32006)]
221    pub type DeleteUser;
222
223    impl Endpoint for DeleteUser {
224        impl Request for DeleteUserRequest;
225        type Response<'de> = ();
226    }
227
228    /// Issues a new registration or reset link for a user, replacing any
229    /// live one. Admins only. Like `POST /api/auth/users/{id}/link`.
230    #[musli(id = 32007)]
231    pub type IssueLink;
232
233    impl Endpoint for IssueLink {
234        impl Request for IssueLinkRequest;
235        type Response<'de> = Link;
236    }
237
238    /// Revokes a user's live link. Admins only. Like
239    /// `DELETE /api/auth/users/{id}/link`.
240    #[musli(id = 32008)]
241    pub type RevokeLink;
242
243    impl Endpoint for RevokeLink {
244        impl Request for RevokeLinkRequest;
245        type Response<'de> = ();
246    }
247
248    /// Ends every session of another user. Admins only. Like
249    /// `POST /api/auth/users/{id}/sign-out`.
250    #[musli(id = 32009)]
251    pub type SignOutUser;
252
253    impl Endpoint for SignOutUser {
254        impl Request for SignOutUserRequest;
255        type Response<'de> = ();
256    }
257
258    /// Revokes another user's access, so they cannot sign in until issued a
259    /// new link. Admins only. Like `POST /api/auth/users/{id}/revoke`.
260    #[musli(id = 32010)]
261    pub type RevokeAccess;
262
263    impl Endpoint for RevokeAccess {
264        impl Request for RevokeAccessRequest;
265        type Response<'de> = UserInfo;
266    }
267
268    /// The Cloudflare Access settings, as stored, or the [`Default`] when
269    /// none are. Admins only. Like `GET /api/auth/cloudflare`.
270    #[musli(id = 32011)]
271    pub type Cloudflare;
272
273    impl Endpoint for Cloudflare {
274        impl Request for CloudflareRequest;
275        type Response<'de> = CloudflareConfig;
276    }
277
278    /// Stores and applies the Cloudflare Access settings, answering with
279    /// them as stored. Admins only. Like `PUT /api/auth/cloudflare`.
280    #[musli(id = 32012)]
281    pub type SetCloudflare;
282
283    impl Endpoint for SetCloudflare {
284        impl Request for CloudflareConfig;
285        type Response<'de> = CloudflareConfig;
286    }
287
288    /// The live auth changes a connection may see, as [`AuthUpdate`]s.
289    #[musli(id = 32100)]
290    pub type AuthBroadcast;
291
292    impl Broadcast for AuthBroadcast {
293        impl Event for AuthUpdate;
294    }
295}
296
297/// A live auth change, the event of [`AuthBroadcast`].
298#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
299#[musli(crate = musli_core02)]
300pub enum AuthUpdate {
301    /// The connection's own user changed: role, login, email or credential.
302    /// Set as [`Me`] answers, so an impersonated session keeps its marker.
303    Me(UserInfo),
304    /// A user was created. Sent to admins only.
305    UserCreated(UserInfo),
306    /// A user changed. Sent to admins only.
307    UserChanged(UserInfo),
308    /// A user was deleted. Sent to admins only.
309    UserDeleted(UserId),
310    /// Updates were missed, so what the page shows of the user list may be
311    /// stale and should be fetched again. Carries the connection's own user
312    /// as [`Me`](AuthUpdate::Me) does. Sent to admins only; a regular user's
313    /// connection gets a `Me` instead.
314    Resync(UserInfo),
315}
316
317/// The request of [`Me`].
318#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Encode, Decode)]
319#[musli(crate = musli_core02)]
320pub struct MeRequest;
321
322/// The request of [`Users`].
323#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Encode, Decode)]
324#[musli(crate = musli_core02)]
325pub struct UsersRequest;
326
327/// The request of [`Cloudflare`].
328#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Encode, Decode)]
329#[musli(crate = musli_core02)]
330pub struct CloudflareRequest;
331
332/// The request of [`UpdateUser`]: the change to make to the user `id`.
333#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
334#[musli(crate = musli_core02)]
335pub struct UserUpdate {
336    /// The user to change.
337    pub id: UserId,
338    /// The change.
339    pub change: UserChange,
340}
341
342/// Declares the requests that only name their target user.
343macro_rules! user_requests {
344    ($($name:ident => $endpoint:ident),* $(,)?) => {
345        $(
346            #[doc = concat!("The request of [`", stringify!($endpoint), "`]: the user it acts on.")]
347            #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)]
348            #[musli(crate = musli_core02)]
349            pub struct $name {
350                /// The user.
351                pub id: UserId,
352            }
353        )*
354    };
355}
356
357user_requests! {
358    DeleteUserRequest => DeleteUser,
359    IssueLinkRequest => IssueLink,
360    RevokeLinkRequest => RevokeLink,
361    SignOutUserRequest => SignOutUser,
362    RevokeAccessRequest => RevokeAccess,
363}