riff_core/wire.rs
1//! Requests, replies and events between `riff` and `riff-server`.
2//!
3//! # Protocol
4//!
5//! Each call is a `POST` with a JSON body. A reply is JSON with status
6//! 200. An error is plain text with status 400, 403, 404 or 409.
7//!
8//! Each call is one type that implements [`Call`]: the trait gives the
9//! path and the type of the reply (01M3WRD8TBDPA4JNEZY6J4N2EX). The
10//! client and the server use the same ones. A command asks for a
11//! change of the state that the log gives. A signal changes only what
12//! the server keeps in memory. A query reads.
13//!
14//! | Path | Request | Reply | Sort |
15//! |---|---|---|---|
16//! | `/v1/register` | [`Register`] | `null` | command |
17//! | `/v1/start` | [`Start`] | [`Started`] | command |
18//! | `/v1/end` | [`End`] | `null` | command |
19//! | `/v1/join` | [`Join`] | `null` | command |
20//! | `/v1/leave` | [`Leave`] | `null` | command |
21//! | `/v1/post` | [`Post`] | [`Posted`] | command |
22//! | `/v1/claim` | [`Claim`] | [`ClaimReply`] | command |
23//! | `/v1/release` | [`Release`] | `null` | command |
24//! | `/v1/release/for` | [`ReleaseFor`] | `null` | command |
25//! | `/v1/lead` | [`Lead`] | [`LeadReply`] | command |
26//! | `/v1/pause` | [`Pause`] | [`RiffReply`] | command |
27//! | `/v1/resume` | [`Resume`] | [`RiffReply`] | command |
28//! | `/v1/idle/set` | [`SetIdle`] | [`Idle`] | command |
29//! | `/v1/plan/hold` | [`Hold`] | [`HoldReply`] | command |
30//! | `/v1/plan/free` | [`Free`] | [`FreeReply`] | command |
31//! | `/v1/plan` | [`SetPlan`] | [`PlanReply`] | command |
32//! | `/v1/plan/off` | [`PlanOff`] | [`PlanOffReply`] | command |
33//! | `/v1/notes/set` | [`SetNotes`] | [`SetNotesReply`] | command |
34//! | `/v1/status` | [`SetStatus`] | `null` | signal |
35//! | `/v1/blocked` | [`SetBlocked`] | `null` | signal |
36//! | `/v1/step` | [`SetStep`] | `null` | signal |
37//! | `/v1/blocked/look` | [`BlockedLook`] | [`BlockedLookReply`] | signal |
38//! | `/v1/items` | [`ItemFacts`] | `null` | signal |
39//! | `/v1/alive` | [`Alive`] | [`AliveReply`] | signal |
40//! | `/v1/plan/seen` | [`PlanSeen`] | [`PlanReply`] | signal |
41//! | `/v1/who` | [`WhoRequest`] | [`WhoReply`] | query |
42//! | `/v1/threads` | [`Threads`] | [`ThreadsReply`] | query |
43//! | `/v1/read` | [`Read`] | [`ReadReply`] | query |
44//! | `/v1/riff` | [`RiffQuery`] | [`RiffReply`] | query |
45//! | `/v1/idle` | [`IdleQuery`] | [`Idle`] | query |
46//! | `/v1/plan/show` | [`PlanShow`] | [`PlanReply`] | query |
47//! | `/v1/notes` | [`NotesShow`] | [`NotesReply`] | query |
48//!
49//! `GET /v1/me` gives [`MeReply`]. `GET /v1/server` gives
50//! [`ServerFacts`], also while the server replies 503 to each other
51//! call.
52//!
53//! `POST /v1/token` is an OAuth 2.1 token endpoint. Its request is a
54//! form, [`TokenRequest`]. Its reply is [`TokenReply`], or
55//! [`TokenError`] with status 400. `GET /v1/sign-in` gives
56//! [`SignInConfig`], or status 404 when the server has no sign-in
57//! provider.
58//!
59//! `POST /v1/revoke` ends each sign-in of one person (R20). It needs
60//! an access token and a DPoP proof. Its request is [`Revoke`] and
61//! its reply is [`Revoked`]. A missing or bad token gets status 401. A
62//! person who is not an admin and names another user gets status 403.
63//!
64//! Five calls change or show who may join the riff
65//! (01M3JN3AHMK532XMRDASD4XD5D). Each needs an access token and a DPoP
66//! proof, like `revoke`:
67//!
68//! | Path | Request | Reply | Who |
69//! |---|---|---|---|
70//! | `POST /v1/invite` | [`Invite`] | [`Invited`] | an admin |
71//! | `POST /v1/remove` | [`Remove`] | [`Removed`] | an admin |
72//! | `POST /v1/members` | [`Members`] | [`MembersReply`] | each person |
73//! | `POST /v1/log` | [`LogQuery`] | [`LogReply`] | an admin |
74//! | `POST /v1/admin` | [`SetAdmin`] | [`AdminSet`] | the owner |
75//! | `POST /v1/owner` | [`PassOwner`] | [`OwnerPassed`] | the owner |
76//!
77//! A person who is not an admin gets status 403 from `invite`,
78//! `remove` and `log`. A person who is not the owner gets status 403 from
79//! `admin` and `owner`. `owner` to a person who is not a member or an
80//! admin gets status 400. `remove` of the owner or of an admin gets status 400.
81//!
82//! Two metadata documents follow the MCP authorization spec (R22):
83//!
84//! | Path | Reply |
85//! |---|---|
86//! | `GET /.well-known/oauth-protected-resource` | [`ResourceMetadata`] (RFC 9728) |
87//! | `GET /.well-known/oauth-authorization-server` | [`ServerMetadata`] (RFC 8414) |
88//!
89//! Two streams use server-sent events. Each event is one `data:` line
90//! that holds JSON:
91//!
92//! | Stream | Query | Event |
93//! |---|---|---|
94//! | `GET /v1/watch` | `uri=<session URI>` | [`Wake`] |
95//! | `GET /v1/tail` | `thread=<thread name>` | [`Tailed`] |
96//!
97//! `GET /v1/me` with the query `uri=<session URI>` gives [`MeReply`]:
98//! only the session of the caller (01M3T5GFVS8NMA992KHZN4VE17). The
99//! status line calls it, not `who`.
100//!
101//! Each stream starts with the comment line `: ready`, so that it sends
102//! its first bytes when it opens. A reader skips each line that is not
103//! a `data:` line.
104//!
105//! A session is live while its watch stream is open. Each request
106//! carries the session URI of its sender as `me`. The server finds the
107//! session by the *who* part of that URI. When the server needs
108//! sign-in, the *who* part must match the token, or the reply is 403
109//! (R104). A session keeps the user of its first call: a known session
110//! ID with another user gets 409 (R159).
111//! Only `register` sets the place of a known session; each other call
112//! uses the URI only to make a session that the server does not know.
113//!
114//! # Example
115//!
116//! A post to the holder of a claim:
117//!
118//! ```
119//! use riff_core::wire::Post;
120//!
121//! let post: Post = serde_json::from_str(r#"{
122//! "me": "riff://mike@pangolin/como-technologies/riff?session=a6cf",
123//! "thread": "como-technologies/riff",
124//! "to": [{ "claim": "issue-6" }],
125//! "body": "The API is ready."
126//! }"#).unwrap();
127//! assert_eq!(post.to[0].claim.as_deref(), Some("issue-6"));
128//! ```
129
130use std::collections::BTreeMap;
131
132use schemars::JsonSchema;
133use serde::de::DeserializeOwned;
134use serde::{Deserialize, Serialize};
135
136use crate::dpop::Key;
137use crate::name::{SessionUri, ThreadName};
138use crate::record::{By, Line, Plan, PlanSet, Record};
139use crate::selector::Selector;
140use crate::signed::Content;
141
142/// The HTTP header of a refused command: the code of the refusal, for
143/// example `held` (01M3WRD9JBQMNN96TXJH8EAJ3W). The text of the reply
144/// is the reason, for a person.
145pub const REFUSED_HEADER: &str = "riff-refused";
146
147/// The HTTP header of the call ID of a command
148/// (01M48VFX22S4811DYBBD7QDW24). Each try of one call sends the same ID.
149/// `riff-server` runs a command with a call ID one time only, and gives
150/// a second try the reply of the first.
151pub const CALL_HEADER: &str = "riff-call";
152
153/// The HTTP header of the reply to a repeated call: `1`. The reply is
154/// the reply of the kept call, on the state of now
155/// (01M48VFX22S4811DYBBD7QDW24).
156pub const REPEAT_HEADER: &str = "riff-repeat";
157
158// ANCHOR: call
159/// A call: one wire type with its path and the type of its reply
160/// (01M3WRD8TBDPA4JNEZY6J4N2EX). The client sends each call with one
161/// generic function, and the server makes one route for each.
162///
163/// ```
164/// use riff_core::wire::{Call, Claim};
165///
166/// /// The path of a call and the JSON of its body.
167/// fn request<C: Call>(call: &C) -> (&'static str, String) {
168/// (C::PATH, serde_json::to_string(call).unwrap())
169/// }
170///
171/// let claim = Claim {
172/// me: "riff://mike@pangolin/como-technologies/riff?session=a6cf".parse()?,
173/// thread: "como-technologies/riff".parse()?,
174/// item: "issue-12".into(),
175/// };
176/// let (path, body) = request(&claim);
177/// assert_eq!(path, "/v1/claim");
178/// assert!(body.contains("issue-12"));
179/// let reply: <Claim as Call>::Reply = serde_json::from_str(
180/// r#"{"holder":"riff://mike@pangolin/como-technologies/riff?session=a6cf&claim=issue-12"}"#,
181/// ).unwrap();
182/// assert_eq!(reply.holder.claims(), ["issue-12"]);
183/// # Ok::<(), riff_core::name::NameError>(())
184/// ```
185pub trait Call: Serialize + DeserializeOwned {
186 /// The HTTP path of the call, for example `/v1/claim`.
187 const PATH: &'static str;
188 /// The reply to the call.
189 type Reply: Serialize + DeserializeOwned;
190}
191// ANCHOR_END: call
192
193/// Gives each call its path and its reply.
194macro_rules! calls {
195 ($($call:ty => $path:literal, $reply:ty;)*) => {
196 $(impl Call for $call {
197 const PATH: &'static str = $path;
198 type Reply = $reply;
199 })*
200 };
201}
202
203calls! {
204 Register => "/v1/register", ();
205 Start => "/v1/start", Started;
206 End => "/v1/end", ();
207 Join => "/v1/join", ();
208 Leave => "/v1/leave", ();
209 Post => "/v1/post", Posted;
210 Claim => "/v1/claim", ClaimReply;
211 Release => "/v1/release", ReleaseReply;
212 ReleaseFor => "/v1/release/for", ();
213 Lead => "/v1/lead", LeadReply;
214 Pause => "/v1/pause", RiffReply;
215 Resume => "/v1/resume", RiffReply;
216 SetIdle => "/v1/idle/set", Idle;
217 Hold => "/v1/plan/hold", HoldReply;
218 Free => "/v1/plan/free", FreeReply;
219 SetPlan => "/v1/plan", PlanReply;
220 PlanOff => "/v1/plan/off", PlanOffReply;
221 PlanSeen => "/v1/plan/seen", PlanReply;
222 PlanShow => "/v1/plan/show", PlanReply;
223 SetNotes => "/v1/notes/set", SetNotesReply;
224 NotesShow => "/v1/notes", NotesReply;
225 SetStatus => "/v1/status", ();
226 SetBlocked => "/v1/blocked", ();
227 SetStep => "/v1/step", ();
228 SetMachine => "/v1/machine", ();
229 BlockedLook => "/v1/blocked/look", BlockedLookReply;
230 ItemFacts => "/v1/items", ();
231 Alive => "/v1/alive", AliveReply;
232 WhoRequest => "/v1/who", WhoReply;
233 Threads => "/v1/threads", ThreadsReply;
234 Read => "/v1/read", ReadReply;
235 RiffQuery => "/v1/riff", RiffReply;
236 IdleQuery => "/v1/idle", Idle;
237 Revoke => "/v1/revoke", Revoked;
238 Invite => "/v1/invite", Invited;
239 Remove => "/v1/remove", Removed;
240 Members => "/v1/members", MembersReply;
241 LogQuery => "/v1/log", LogReply;
242 SetAdmin => "/v1/admin", AdminSet;
243 PassOwner => "/v1/owner", OwnerPassed;
244 TakeOwner => "/v1/owner/take", OwnerAsked;
245 DenyOwner => "/v1/owner/deny", OwnerDenied;
246}
247
248/// `POST /v1/register`: a session says that it exists and where it
249/// works. A session registers when it starts and when it moves. It
250/// joins the thread of its repository. A worker says that it is a
251/// worker, so `who` shows it the same on each machine
252/// (01M3NT4M159EHN5W8JRTQ417N4).
253///
254/// ```
255/// use riff_core::wire::Register;
256///
257/// let old: Register = serde_json::from_str(r#"{"me":"riff://mike@pangolin/o/r?session=a1"}"#).unwrap();
258/// assert!(!old.worker);
259/// ```
260#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
261pub struct Register {
262 pub me: SessionUri,
263 /// True when the session is a worker: `riff workers run` started it.
264 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
265 pub worker: bool,
266}
267
268/// `riff mcp` sends a keep-alive this often, also while no turn runs
269/// (R204). A session with no sign of life for 3 minutes is gone.
270pub const ALIVE_EVERY: std::time::Duration = std::time::Duration::from_secs(60);
271
272/// `POST /v1/alive`: a keep-alive. It shows that the session still runs,
273/// but it is not a call: the idle time in `who` stays (R204).
274#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
275pub struct Alive {
276 pub me: SessionUri,
277 /// The newest fact of the hooks of the session on its machine, when
278 /// it has one (01M41FZNTPXQNCZ1S99HE42PYQ).
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub activity: Option<Activity>,
281 /// The seconds since the last prompt of the person in the session,
282 /// when the prompt hook saw one (01M48VDWPDYRPEAXHR1MYDN1M7).
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub prompt_secs: Option<u64>,
285 /// True when the riff daemon sends the keep-alive for a session in
286 /// a container. The session is live while it is not gone
287 /// (01M510MG0CKDXAKF6YJ768K7E8).
288 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
289 pub container: bool,
290 /// The riff of the process that lives as long as the session:
291 /// `riff mcp`, the riff daemon, `riff run` or `riff workers host`.
292 /// Each other keep-alive sends none, and the server keeps the last
293 /// one (01M514C11BPE93PJT4221PYVHS).
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub binary: Option<RiffBinary>,
296}
297
298/// The riff that a session runs: its build, and whether its file was
299/// replaced after the start (01M514C11BPE93PJT4221PYVHS).
300///
301/// ```
302/// use riff_core::build::Build;
303/// use riff_core::wire::RiffBinary;
304///
305/// let server: Build = "1.4.0 122daab96637 2026-10-15T23:08:34Z".parse().unwrap();
306/// let binary = |build: &str, replaced| RiffBinary { build: build.into(), replaced };
307/// let old = binary("1.3.1 aaaaaaaaaaaa 2026-10-14T10:00:00Z", false);
308/// assert!(old.out_of_date(Some(&server)));
309/// // The same version from an older commit is older too.
310/// assert!(binary("1.4.0 bbbbbbbbbbbb 2026-10-15T20:00:00Z", false).out_of_date(Some(&server)));
311/// // The build of the server, or a newer one, is current.
312/// assert!(!binary(&server.to_string(), false).out_of_date(Some(&server)));
313/// assert!(!binary("1.4.1 cccccccccccc 2026-10-16T10:00:00Z", false).out_of_date(Some(&server)));
314/// // A replaced file is out of date with each build.
315/// assert!(binary(&server.to_string(), true).out_of_date(Some(&server)));
316/// assert!(binary(&server.to_string(), true).out_of_date(None));
317/// // With no build of the server, or no build that riff can read,
318/// // only a replaced file counts.
319/// assert!(!old.out_of_date(None));
320/// assert!(!binary("next", false).out_of_date(Some(&server)));
321/// ```
322#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
323pub struct RiffBinary {
324 /// The build of the riff ([`crate::build::VERSION`]).
325 pub build: String,
326 /// True when the file of the riff was replaced after the start of
327 /// the process: Linux names it `PATH (deleted)`.
328 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
329 pub replaced: bool,
330}
331
332impl RiffBinary {
333 /// True when the session must update: its file was replaced, or its
334 /// build is older than the build of the server, `server`
335 /// (01M514C142K5BM2746F1D9Y448, 01M514C19H3WQP1FSX9RBVSC69).
336 pub fn out_of_date(&self, server: Option<&crate::build::Build>) -> bool {
337 self.replaced
338 || server.is_some_and(|server| {
339 self.build
340 .parse::<crate::build::Build>()
341 .is_ok_and(|build| build.older_than(server))
342 })
343 }
344}
345
346/// The most characters in the text of a tool of an [`Activity`].
347pub const ACTIVITY_CHARS: usize = 80;
348
349/// What the hooks of a session saw last (01M41FZNTPXQNCZ1S99HE42PYQ):
350/// a tool that runs, a turn that runs between two tools, or a turn
351/// that ended. A hook writes it on the machine at each tool call and at
352/// the end of each turn, and makes no call. `riff mcp` sends the newest
353/// one in its keep-alive. A riff tool and `riff watch` give no fact:
354/// they are no work.
355///
356/// ```
357/// use riff_core::wire::Activity;
358///
359/// let tool = Activity { tool: Some("Bash: run just ci".into()), turn: true, secs: 30 };
360/// assert!(tool.works());
361/// let between = Activity { tool: None, turn: true, secs: 2 };
362/// assert!(between.works());
363/// let ended = Activity { tool: None, turn: false, secs: 5 };
364/// assert!(!ended.works());
365/// let json = serde_json::to_string(&ended).unwrap();
366/// assert_eq!(json, r#"{"secs":5}"#);
367/// ```
368#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
369pub struct Activity {
370 /// The tool that runs, with its short text: `Bash: run just ci`.
371 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub tool: Option<String>,
373 /// True while a turn runs. False when the turn ended.
374 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
375 pub turn: bool,
376 /// The seconds since the fact.
377 pub secs: u64,
378}
379
380impl Activity {
381 /// True while a turn runs: a sign of work.
382 pub fn works(&self) -> bool {
383 self.turn
384 }
385}
386
387/// The reply to [`Alive`].
388#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
389pub struct AliveReply {
390 /// True when the session is a worker that must clear its context
391 /// before its next claim (01M3X9XB37TQCXWPNFZRMRGJB4). The reply to its last release
392 /// said so too.
393 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
394 pub clear: bool,
395}
396
397/// A worker session sends a keep-alive this often, so that it stops soon
398/// after the server asks (01M3Q5A0NKY1FCS0YH6N6YD3GN).
399pub const WORKER_ALIVE_EVERY: std::time::Duration = std::time::Duration::from_secs(10);
400
401/// `POST /v1/end`: the session ended (R205). It leaves `who`, and its
402/// claims are free at once.
403#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
404pub struct End {
405 pub me: SessionUri,
406}
407
408/// `POST /v1/who`: lists the known sessions. The call counts as a call
409/// of `me`.
410#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
411pub struct WhoRequest {
412 pub me: SessionUri,
413 /// True lists gone sessions too.
414 #[serde(default)]
415 pub all: bool,
416}
417
418#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
419pub struct WhoReply {
420 pub sessions: Vec<SessionInfo>,
421 /// The owner of the riff (01M3Q63NK0AHM25MB258B0K8XP).
422 #[serde(default)]
423 pub owner: RiffOwner,
424 /// Each member of the riff with a USER, also when away
425 /// (01M3NT4M3A4E3K5S2NM7MS6PQD). A riff with no sign-in has none.
426 #[serde(default, skip_serializing_if = "Vec::is_empty")]
427 pub people: Vec<Person>,
428}
429
430/// The reply to `GET /v1/me`: only the session of the caller, and the
431/// build of the server (01M3T5GFVS8NMA992KHZN4VE17). `session` is None
432/// when the server does not know the session.
433///
434/// ```
435/// use riff_core::wire::MeReply;
436///
437/// let reply: MeReply =
438/// serde_json::from_str(r#"{"session":null,"build":"0.8.0 e3cfe5a2919c 2026-09-29T21:12:48Z"}"#)?;
439/// assert!(reply.session.is_none());
440/// assert_eq!(reply.build, "0.8.0 e3cfe5a2919c 2026-09-29T21:12:48Z");
441/// # Ok::<(), serde_json::Error>(())
442/// ```
443#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
444pub struct MeReply {
445 /// The session of the caller, as `who` shows it.
446 pub session: Option<SessionInfo>,
447 /// The build of the server, as the `riff-build` header gives it.
448 pub build: String,
449}
450
451/// The reply to `GET /v1/server`: the facts of one `riff-server`, for
452/// `riff server` (01M3TJWJ12WEDCXW3W0529KRP2). Each field has a default,
453/// so a `riff` of another build reads the reply. Each time is in
454/// milliseconds since the Unix epoch, on the clock of the server.
455///
456/// ```
457/// use riff_core::wire::ServerFacts;
458///
459/// let facts: ServerFacts = serde_json::from_str(r#"{"position":7,"later":true}"#)?;
460/// assert_eq!(facts.position, 7);
461/// assert!(facts.not_serving.is_none() && facts.checkpoint.is_none());
462/// # Ok::<(), serde_json::Error>(())
463/// ```
464#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
465#[serde(default)]
466pub struct ServerFacts {
467 /// Why the server replies 503 to each other call. `None`: it serves.
468 pub not_serving: Option<String>,
469 /// The last error of the server since its start.
470 pub last_error: Option<FactError>,
471 /// The position of the last written record of the log.
472 pub position: u64,
473 /// The time of the last chunk write.
474 pub chunk_written_at_ms: Option<u64>,
475 /// How long the last chunk write took, in milliseconds.
476 pub chunk_write_ms: Option<u64>,
477 /// The number of failed tries of a chunk write since the start.
478 pub write_errors: u64,
479 /// The number of records that this build skipped since the start.
480 pub skipped_records: u64,
481 /// The newest checkpoint.
482 pub checkpoint: Option<CheckpointFacts>,
483 /// Why this build writes no checkpoint. `None`: it writes them.
484 pub no_checkpoint: Option<String>,
485 /// The number of chunks in the store.
486 pub chunks: u64,
487 pub sessions: u64,
488 /// The number of read cursors.
489 pub cursors: u64,
490 pub threads: u64,
491 /// The number of live token chains: one for each sign-in.
492 pub sign_ins: u64,
493 /// The memory that the server uses, in bytes. `None` when the
494 /// system does not tell.
495 pub memory_bytes: Option<u64>,
496 /// The start time of the instance.
497 pub started_at_ms: u64,
498 /// How long the load and the replay took, in milliseconds.
499 pub replay_ms: u64,
500 /// The time of this reply.
501 pub now_ms: u64,
502}
503
504/// An error of the server in [`ServerFacts`].
505#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
506#[serde(default)]
507pub struct FactError {
508 pub message: String,
509 pub at_ms: u64,
510}
511
512/// The newest checkpoint in [`ServerFacts`].
513#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
514#[serde(default)]
515pub struct CheckpointFacts {
516 pub position: u64,
517 pub written_at_ms: u64,
518 /// The version of the build that wrote it.
519 pub build: String,
520}
521
522/// A member of the riff in the reply to `who`: a person, not a session
523/// (01M3NT4M3A4E3K5S2NM7MS6PQD).
524///
525/// ```
526/// use riff_core::wire::{Person, PersonRole};
527///
528/// let ada: Person =
529/// serde_json::from_str(r#"{"user":"ada","role":"owner","live":false,"seen_secs":3600}"#).unwrap();
530/// assert_eq!(ada.role, PersonRole::Owner);
531/// assert_eq!(ada.role.tag(), Some("owner"));
532/// assert_eq!(PersonRole::Member.tag(), None);
533/// assert_eq!(ada.seen_secs, Some(3600));
534/// ```
535#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
536pub struct Person {
537 /// The USER of the person.
538 pub user: String,
539 pub role: PersonRole,
540 /// True while a session of the person is live.
541 pub live: bool,
542 /// The seconds since the last call of a session of the person.
543 /// `None` when the server knows no session of the person.
544 #[serde(default, skip_serializing_if = "Option::is_none")]
545 pub seen_secs: Option<u64>,
546}
547
548/// The role of a person in the riff.
549#[derive(
550 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
551)]
552#[serde(rename_all = "snake_case")]
553pub enum PersonRole {
554 Owner,
555 Admin,
556 Member,
557}
558
559impl PersonRole {
560 /// The tag on the row of the person: `owner`, `admin`, or none for
561 /// a member.
562 pub fn tag(self) -> Option<&'static str> {
563 match self {
564 PersonRole::Owner => Some("owner"),
565 PersonRole::Admin => Some("admin"),
566 PersonRole::Member => None,
567 }
568 }
569}
570
571/// The owner of the riff, in the reply to `who`
572/// (01M3Q63NK0AHM25MB258B0K8XP). A reply with no owner field reads as
573/// [`RiffOwner::NoSignIn`].
574///
575/// ```
576/// use riff_core::wire::{RiffOwner, WhoReply};
577///
578/// let reply: WhoReply = serde_json::from_str(
579/// r#"{"sessions":[],"owner":{"kind":"owner","user":"ada","email":"ada@gmail.com"}}"#,
580/// ).unwrap();
581/// assert_eq!(reply.owner, RiffOwner::Owner { user: "ada".into(), email: "ada@gmail.com".into() });
582/// assert!(reply.owner.is("ada") && !reply.owner.is("bob"));
583///
584/// let old: WhoReply = serde_json::from_str(r#"{"sessions":[]}"#).unwrap();
585/// assert_eq!(old.owner, RiffOwner::NoSignIn);
586/// assert!(!RiffOwner::Nobody.is("ada"));
587/// ```
588#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
589#[serde(tag = "kind", rename_all = "snake_case")]
590pub enum RiffOwner {
591 /// A riff with no sign-in: it shows no owner.
592 #[default]
593 NoSignIn,
594 /// A riff with sign-in and no owner yet.
595 Nobody,
596 /// The owner: the USER and the email, in lower case.
597 Owner { user: String, email: String },
598}
599
600impl RiffOwner {
601 /// True when `user` is the owner.
602 pub fn is(&self, user: &str) -> bool {
603 matches!(self, RiffOwner::Owner { user: owner, .. } if owner == user)
604 }
605}
606
607#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
608pub struct SessionInfo {
609 /// The URI now: the place and the claims are current.
610 pub uri: SessionUri,
611 /// True while the session has an open watch stream. A lead and a
612 /// session in a container are live while they are not gone, also
613 /// with no watch (01M48VDGQ5KETKPM4G6TKTC2MB,
614 /// 01M510MG0CKDXAKF6YJ768K7E8).
615 pub live: bool,
616 /// The seconds since the last call of the session. 0 while it is
617 /// live.
618 #[serde(default)]
619 pub idle_secs: u64,
620 /// The last status that the session set, if it set one.
621 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub status: Option<StatusInfo>,
623 /// True when the session registered as a worker
624 /// (01M3NT4M159EHN5W8JRTQ417N4).
625 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
626 pub worker: bool,
627 /// True when the riff daemon keeps the session alive: it runs in a
628 /// container. It is live while it is not gone
629 /// (01M510MG0CKDXAKF6YJ768K7E8).
630 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
631 pub container: bool,
632 /// The riff that the session runs, from its last keep-alive that
633 /// named one. `None` when no keep-alive named one, as from an older
634 /// riff (01M514C11BPE93PJT4221PYVHS).
635 #[serde(default, skip_serializing_if = "Option::is_none")]
636 pub binary: Option<RiffBinary>,
637 /// The seconds since the claims of the session last changed: a
638 /// claim, a release, a new start, or the start of `riff-server`. A
639 /// worker with no claim is idle for this time
640 /// (01M3Q551WCMPQRCNJ8FXQEBFY4).
641 #[serde(default)]
642 pub claims_secs: u64,
643 /// True when the session is a worker that must clear its context
644 /// before its next claim (01M3X9XAK1KPZZVM1AJR2H8DSS).
645 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
646 pub must_clear: bool,
647 /// The seconds since the last fresh start of the session: a new
648 /// agent process or a clear of its context. `None` when the log has
649 /// no such start (01M3X9XC99KY4RQY36A7CYWY11).
650 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub fresh_secs: Option<u64>,
652 /// The state of the session, that the server derives
653 /// (01M3QB6CJ1XCQG5B1BVR8AF3B4). An older server sends none: see
654 /// [`SessionInfo::fill_state`].
655 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub state: Option<SessionState>,
657 /// The newest fact of the hooks of the session, with its age now
658 /// (01M41FZNTPXQNCZ1S99HE42PYQ).
659 #[serde(default, skip_serializing_if = "Option::is_none")]
660 pub work: Option<Activity>,
661 /// What each claim of the session waits for, when each one waits
662 /// (01M41FZP9A50CH4A2VX344DW49).
663 #[serde(default, skip_serializing_if = "Option::is_none")]
664 pub waits: Option<Waits>,
665 /// The block of the session, while it holds
666 /// (01M41FZPGEK4TNPSM2051W4VMS).
667 #[serde(default, skip_serializing_if = "Option::is_none")]
668 pub blocked: Option<BlockedInfo>,
669 /// The long step of the session, until it is done
670 /// (01M48VDGTD40P8RBZMS0XB5M9N).
671 #[serde(default, skip_serializing_if = "Option::is_none")]
672 pub step: Option<StepInfo>,
673 /// The last numbers of the machine of the session, with their age,
674 /// when it sent some (01M515KSWVGCBS76AWZV74TJQ3).
675 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub machine: Option<MachineInfo>,
677}
678
679impl SessionInfo {
680 /// Sets the state from the other facts of the session when the
681 /// server sent none, as an older server does. `riff` is the state of
682 /// the riff. A state from the server stays.
683 ///
684 /// ```
685 /// use riff_core::wire::{RiffState, SessionInfo, SessionState};
686 ///
687 /// // A who reply of an older server: no state.
688 /// let json = r#"{"uri":"riff://mike@thelio/o/r?session=w1&claim=issue-12","live":true}"#;
689 /// let mut s: SessionInfo = serde_json::from_str(json).unwrap();
690 /// assert_eq!(s.state, None);
691 /// s.fill_state(RiffState::Running);
692 /// assert_eq!(s.state, Some(SessionState::Busy));
693 /// s.fill_state(RiffState::Paused);
694 /// assert_eq!(s.state, Some(SessionState::Busy), "a state stays");
695 /// ```
696 pub fn fill_state(&mut self, riff: RiffState) {
697 if self.state.is_some() {
698 return;
699 }
700 self.state = Some(SessionState::of(&Facts {
701 live: self.live,
702 paused: riff == RiffState::Paused,
703 blocked: self.blocked.is_some(),
704 must_clear: self.must_clear,
705 waiting: self.waits.is_some(),
706 claims: !self.uri.claims().is_empty(),
707 lead: self.uri.lead(),
708 turn: self.work.as_ref().is_some_and(Activity::works),
709 }));
710 }
711}
712
713/// The state of a session. The server derives it from facts; no
714/// session reports it (01M3QB6CJ1XCQG5B1BVR8AF3B4,
715/// 01M41FZQVEF8S2W9RCM4V87C3D). The first state that matches wins, in
716/// this order:
717///
718/// 1. `offline`: the session has no open watch stream. A lead is not
719/// offline while it is not gone, also with no watch
720/// (01M48VDGQ5KETKPM4G6TKTC2MB). A session in a container is not
721/// offline while the keep-alive of the riff daemon comes
722/// (01M510MG0CKDXAKF6YJ768K7E8).
723/// 2. `paused`: the riff is paused, or the repository of the session is
724/// paused (01M3XAHZBGSSJB3YX23K88W01K).
725/// 3. `blocked`: the session said that it cannot go on with no
726/// decision, and the block holds (01M41FZPGEK4TNPSM2051W4VMS). A
727/// lead with a block is `waiting` for its person
728/// (01M48VDS8RKJS9HG3KSEYGBFGV).
729/// 4. `must_clear`: it is a worker that must clear its context before
730/// its next claim (01M3X9XAK1KPZZVM1AJR2H8DSS).
731/// 5. `waiting`: each claim of the session waits for the machinery: a
732/// verify, a merge, or an item of its `Needs:` line
733/// (01M41FZP9A50CH4A2VX344DW49). Or a lead waits for its person.
734/// 6. `busy`: it holds a claim. Or it is the lead, and a turn runs.
735/// 7. `idle`: each other session.
736///
737/// ```mermaid
738/// stateDiagram-v2
739/// [*] --> idle
740/// idle --> busy: claim
741/// busy --> waiting: a verify is asked, a merge waits, a need is open
742/// waiting --> busy: the fact ends
743/// busy --> blocked: blocked REASON (wakes the lead)
744/// blocked --> busy: an answer, then a sign of work
745/// busy --> must_clear: the last release of a worker
746/// must_clear --> idle: a clear
747/// ```
748///
749/// ```
750/// use riff_core::wire::{Facts, SessionState};
751///
752/// let all = Facts {
753/// live: true, paused: true, blocked: true, must_clear: true,
754/// waiting: true, claims: true, lead: false, turn: false,
755/// };
756/// let of = |f: Facts| SessionState::of(&f);
757/// assert_eq!(of(Facts { live: false, ..all }), SessionState::Offline);
758/// assert_eq!(of(all), SessionState::Paused);
759/// let running = Facts { paused: false, ..all };
760/// assert_eq!(of(running), SessionState::Blocked);
761/// let free = Facts { blocked: false, ..running };
762/// assert_eq!(of(free), SessionState::MustClear);
763/// let cleared = Facts { must_clear: false, ..free };
764/// assert_eq!(of(cleared), SessionState::Waiting);
765/// assert_eq!(of(Facts { waiting: false, ..cleared }), SessionState::Busy);
766/// let none = Facts { waiting: false, claims: false, ..cleared };
767/// assert_eq!(of(none), SessionState::Idle);
768///
769/// // A lead: busy in a turn, waiting for its person, idle else.
770/// let lead = Facts { lead: true, ..none };
771/// assert_eq!(of(lead), SessionState::Idle);
772/// assert_eq!(of(Facts { turn: true, ..lead }), SessionState::Busy);
773/// assert_eq!(of(Facts { blocked: true, turn: true, ..lead }), SessionState::Waiting);
774///
775/// assert_eq!(serde_json::to_string(&SessionState::MustClear).unwrap(), r#""must_clear""#);
776/// assert_eq!(SessionState::MustClear.word(), "must clear");
777/// assert_eq!(serde_json::to_string(&SessionState::Waiting).unwrap(), r#""waiting""#);
778/// assert_eq!(SessionState::Blocked.word(), "blocked");
779/// ```
780#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
781#[serde(rename_all = "snake_case")]
782pub enum SessionState {
783 #[default]
784 Offline,
785 Paused,
786 Blocked,
787 MustClear,
788 Waiting,
789 Busy,
790 Idle,
791}
792
793/// The facts that make the state of a session ([`SessionState::of`]).
794#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
795pub struct Facts {
796 /// An open watch stream, or a lead or a session in a container
797 /// that is not gone.
798 pub live: bool,
799 /// The riff or the repository of the session is paused.
800 pub paused: bool,
801 /// A block that holds.
802 pub blocked: bool,
803 /// A worker that must clear its context.
804 pub must_clear: bool,
805 /// Each claim waits.
806 pub waiting: bool,
807 /// The session holds a claim.
808 pub claims: bool,
809 /// The session is the lead of its user.
810 pub lead: bool,
811 /// The hooks saw a turn that runs.
812 pub turn: bool,
813}
814
815impl SessionState {
816 /// The state of a session from its facts. A lead has no claims, so
817 /// its state comes from its turn and its block
818 /// (01M48VDS8RKJS9HG3KSEYGBFGV).
819 pub fn of(f: &Facts) -> Self {
820 if !f.live {
821 SessionState::Offline
822 } else if f.paused {
823 SessionState::Paused
824 } else if f.blocked && !f.lead {
825 SessionState::Blocked
826 } else if f.must_clear {
827 SessionState::MustClear
828 } else if (f.waiting && f.claims) || (f.blocked && f.lead) {
829 SessionState::Waiting
830 } else if f.claims || (f.lead && f.turn) {
831 SessionState::Busy
832 } else {
833 SessionState::Idle
834 }
835 }
836
837 /// The word of the state, the same in each command.
838 pub fn word(self) -> &'static str {
839 match self {
840 SessionState::Offline => "offline",
841 SessionState::Paused => "paused",
842 SessionState::Blocked => "blocked",
843 SessionState::MustClear => "must clear",
844 SessionState::Waiting => "waiting",
845 SessionState::Busy => "busy",
846 SessionState::Idle => "idle",
847 }
848 }
849}
850
851/// What the claims of a session wait for (01M41FZP9A50CH4A2VX344DW49).
852/// The server makes it from the facts of the items ([`ItemFact`]).
853/// Nobody must decide, so it wakes nobody.
854///
855/// ```
856/// use riff_core::wire::Waits;
857///
858/// assert_eq!(Waits::Verify { pull: 418 }.to_string(), "waits for a verify of PR #418");
859/// assert_eq!(Waits::Merge { pull: 418 }.to_string(), "waits for the merge of PR #418");
860/// assert_eq!(Waits::Needs { issues: vec![12, 15] }.to_string(), "waits for #12, #15");
861/// let json = serde_json::to_string(&Waits::Merge { pull: 418 }).unwrap();
862/// assert_eq!(json, r#"{"for":"merge","pull":418}"#);
863/// ```
864#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
865#[serde(tag = "for", rename_all = "snake_case")]
866pub enum Waits {
867 /// The pull request of the item waits for a verify.
868 Verify { pull: u64 },
869 /// The verify passed: the pull request waits for the merge.
870 Merge { pull: u64 },
871 /// Items of the `Needs:` line of the item are open.
872 Needs { issues: Vec<u64> },
873}
874
875impl std::fmt::Display for Waits {
876 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
877 match self {
878 Waits::Verify { pull } => write!(f, "waits for a verify of PR #{pull}"),
879 Waits::Merge { pull } => write!(f, "waits for the merge of PR #{pull}"),
880 Waits::Needs { issues } => {
881 let issues: Vec<String> = issues.iter().map(|n| format!("#{n}")).collect();
882 write!(f, "waits for {}", issues.join(", "))
883 }
884 }
885 }
886}
887
888/// A block in the reply to `who` (01M41FZPGEK4TNPSM2051W4VMS).
889#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
890pub struct BlockedInfo {
891 /// Why the session cannot go on.
892 pub reason: String,
893 /// The seconds since the session said it.
894 pub secs: u64,
895 /// True when a message woke the session after the block. The block
896 /// ends at the next sign of work (01M41FZPT31ATXP75QW965P3JB).
897 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
898 pub answered: bool,
899 /// True when riff woke the lead a second time
900 /// (01M41FZQ545HQ9Q75CSKX8HF8H).
901 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
902 pub woken_again: bool,
903 /// True when the lead gave no answer after the second wake
904 /// (01M41FZQCHWY1YVGAZ60ZHJK21).
905 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
906 pub unanswered: bool,
907}
908
909/// The most characters in the step of a [`Status`], and in the reason
910/// of a [`SetBlocked`].
911pub const STATUS_CHARS: usize = 400;
912
913/// The most characters in the numbers of a [`SetMachine`]: the facts of
914/// the daemon of a host are longer than a status
915/// (01M57D3F50347RD8ED79KEA9V7).
916pub const MACHINE_CHARS: usize = 4000;
917
918/// What a session does now, in its own words: its current step. The
919/// words help a person. They make no state: a block is [`SetBlocked`]
920/// (01M41FZPGEK4TNPSM2051W4VMS).
921#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
922pub struct Status {
923 pub step: String,
924}
925
926impl Status {
927 /// Refuses a status that `who` cannot show on one line: an empty
928 /// step, a line break, or more than [`STATUS_CHARS`] characters.
929 ///
930 /// ```
931 /// use riff_core::wire::Status;
932 ///
933 /// let status = |step: &str| Status { step: step.into() };
934 /// assert!(status("write the tests").check().is_ok());
935 /// assert!(status(" ").check().is_err());
936 /// assert!(status("two\nlines").check().is_err());
937 /// assert!(status(&"x".repeat(401)).check().is_err());
938 /// ```
939 pub fn check(&self) -> Result<(), String> {
940 one_line("step", &self.step)
941 }
942}
943
944/// Refuses a text that `who` cannot show on one line: empty, with a
945/// line break, or with more than [`STATUS_CHARS`] characters. `what`
946/// names the text in the error.
947fn one_line(what: &str, text: &str) -> Result<(), String> {
948 line_of(what, text, STATUS_CHARS)
949}
950
951/// Refuses a text that is empty, has a line break, or has more than
952/// `most` characters.
953fn line_of(what: &str, text: &str, most: usize) -> Result<(), String> {
954 if text.trim().is_empty() {
955 return Err(format!("the {what} is empty"));
956 }
957 if text.chars().any(char::is_control) {
958 return Err(format!("the {what} must be one line"));
959 }
960 if text.chars().count() > most {
961 return Err(format!("the {what} has more than {most} characters"));
962 }
963 Ok(())
964}
965
966/// A [`Status`] in the reply to `who`, with its age.
967#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
968pub struct StatusInfo {
969 #[serde(flatten)]
970 pub status: Status,
971 /// The seconds since the session set the status.
972 pub age_secs: u64,
973 /// True when the session set the status before the last change of
974 /// its state: a claim or a release, a pause or a resume of the
975 /// riff, or a start of `riff-server` (01M3Q551YHYZBFV2NDS1QCYXCD).
976 /// A stale status is not the current state of the session.
977 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
978 pub stale: bool,
979}
980
981/// `POST /v1/status`: sets the status of `me`. It replaces the old
982/// status.
983#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
984pub struct SetStatus {
985 pub me: SessionUri,
986 pub status: Status,
987}
988
989/// `POST /v1/blocked`: `me` cannot go on with no decision
990/// (01M41FZPGEK4TNPSM2051W4VMS). It is a signal. The `blocked` tool and
991/// `riff blocked` send it with the message that wakes the lead, in one
992/// command: a session does neither alone.
993///
994/// ```
995/// use riff_core::wire::SetBlocked;
996///
997/// let me: riff_core::name::SessionUri = "riff://mike@pangolin/o/r?session=a1".parse()?;
998/// let set = |reason: &str| SetBlocked { me: me.clone(), reason: reason.into() };
999/// assert!(set("which of the two designs?").check().is_ok());
1000/// assert_eq!(set(" ").check().unwrap_err(), "the reason is empty");
1001/// # Ok::<(), riff_core::name::NameError>(())
1002/// ```
1003#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1004pub struct SetBlocked {
1005 pub me: SessionUri,
1006 pub reason: String,
1007}
1008
1009impl SetBlocked {
1010 /// Refuses a reason that is not one line of at most
1011 /// [`STATUS_CHARS`] characters.
1012 pub fn check(&self) -> Result<(), String> {
1013 one_line("reason", &self.reason)
1014 }
1015}
1016
1017/// A change of the long step of a session (01M48VDGTD40P8RBZMS0XB5M9N):
1018/// a step that runs longer than a tool call, for example a live window
1019/// of two hours.
1020///
1021/// ```
1022/// use riff_core::wire::StepChange;
1023///
1024/// let start = StepChange::Start { name: "live window".into() };
1025/// assert_eq!(serde_json::to_string(&start).unwrap(), r#"{"start":{"name":"live window"}}"#);
1026/// assert_eq!(serde_json::to_string(&StepChange::Done).unwrap(), r#""done""#);
1027/// ```
1028#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1029#[serde(rename_all = "snake_case")]
1030pub enum StepChange {
1031 /// A new step starts. It replaces the old step.
1032 Start { name: String },
1033 /// The step ended well. No step shows.
1034 Done,
1035 /// The step failed, for `reason`. It shows until the next change.
1036 Fail { reason: String },
1037}
1038
1039/// `POST /v1/step`: a change of the long step of `me`
1040/// (01M48VDGTD40P8RBZMS0XB5M9N). It is a signal, and a sign of life.
1041/// `riff step fail` also wakes the lead (01M48VDS663X064YS5ZGCCZSTB).
1042///
1043/// ```
1044/// use riff_core::wire::{SetStep, StepChange};
1045///
1046/// let me: riff_core::name::SessionUri = "riff://mike@pangolin/o/r?session=a1".parse()?;
1047/// let set = |change| SetStep { me: me.clone(), change };
1048/// assert!(set(StepChange::Start { name: "live window".into() }).check().is_ok());
1049/// assert!(set(StepChange::Done).check().is_ok());
1050/// assert_eq!(
1051/// set(StepChange::Fail { reason: " ".into() }).check().unwrap_err(),
1052/// "the reason is empty"
1053/// );
1054/// assert_eq!(
1055/// set(StepChange::Start { name: "a\nb".into() }).check().unwrap_err(),
1056/// "the name must be one line"
1057/// );
1058/// # Ok::<(), riff_core::name::NameError>(())
1059/// ```
1060#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1061pub struct SetStep {
1062 pub me: SessionUri,
1063 pub change: StepChange,
1064}
1065
1066impl SetStep {
1067 /// Refuses a name or a reason that is not one line of at most
1068 /// [`STATUS_CHARS`] characters.
1069 pub fn check(&self) -> Result<(), String> {
1070 match &self.change {
1071 StepChange::Start { name } => one_line("name", name),
1072 StepChange::Done => Ok(()),
1073 StepChange::Fail { reason } => one_line("reason", reason),
1074 }
1075 }
1076}
1077
1078/// `POST /v1/machine`: the numbers of the machine of `me`
1079/// (01M515KSWVGCBS76AWZV74TJQ3). The `riff mcp` of the lead sends them
1080/// for its machine, which has no `riff workers host`. The numbers are
1081/// the text of the status of a workers host. The daemon of a host sends
1082/// its facts in the same way (01M57D3F50347RD8ED79KEA9V7). riff-server
1083/// keeps the text and does not read it. It is a signal, like a status.
1084///
1085/// ```
1086/// use riff_core::wire::SetMachine;
1087///
1088/// let me: riff_core::name::SessionUri = "riff://mike@pangolin/o/r?session=a1".parse()?;
1089/// let set = |numbers: &str| SetMachine { me: me.clone(), numbers: numbers.into() };
1090/// assert!(set("workers host: limit 2, floor 4GB, no workers").check().is_ok());
1091/// assert_eq!(set(" ").check().unwrap_err(), "the text of the numbers is empty");
1092/// assert!(set(&"x".repeat(4000)).check().is_ok());
1093/// assert!(set(&"x".repeat(4001)).check().is_err());
1094/// assert!(set("two\nlines").check().is_err());
1095/// # Ok::<(), riff_core::name::NameError>(())
1096/// ```
1097#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1098pub struct SetMachine {
1099 pub me: SessionUri,
1100 pub numbers: String,
1101}
1102
1103impl SetMachine {
1104 /// Refuses numbers that are not one line of at most
1105 /// [`MACHINE_CHARS`] characters.
1106 pub fn check(&self) -> Result<(), String> {
1107 line_of("text of the numbers", &self.numbers, MACHINE_CHARS)
1108 }
1109}
1110
1111/// The numbers of the machine of a session in the reply to `who`
1112/// (01M515KSWVGCBS76AWZV74TJQ3).
1113#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1114pub struct MachineInfo {
1115 /// The text of [`SetMachine`].
1116 pub numbers: String,
1117 /// The seconds since the session sent them.
1118 pub age_secs: u64,
1119}
1120
1121/// A long step in the reply to `who` (01M48VDGTD40P8RBZMS0XB5M9N).
1122#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1123pub struct StepInfo {
1124 /// The name of the step.
1125 pub name: String,
1126 /// The seconds since the step started, or since it failed.
1127 pub secs: u64,
1128 /// The reason, when the step failed.
1129 #[serde(default, skip_serializing_if = "Option::is_none")]
1130 pub failed: Option<String>,
1131}
1132
1133/// `POST /v1/blocked/look`: the lead `me` looks at the blocks of the
1134/// sessions of its user in its repository. A block with no answer for
1135/// `after_secs` wakes the lead again (01M41FZQ545HQ9Q75CSKX8HF8H). A
1136/// block with no answer for `after_secs` after that is unanswered
1137/// (01M41FZQCHWY1YVGAZ60ZHJK21). The server refuses a caller that is not
1138/// the lead.
1139#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1140pub struct BlockedLook {
1141 pub me: SessionUri,
1142 pub after_secs: u64,
1143}
1144
1145/// The reply to [`BlockedLook`]: each block that this look made
1146/// unanswered. The `riff mcp` of the lead shows each one in a desktop
1147/// notification (01M41FZQKZKW131Z8822G31T5G).
1148#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1149pub struct BlockedLookReply {
1150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1151 pub unanswered: Vec<Unanswered>,
1152}
1153
1154/// A block with no answer of the lead: the session with its claims, and
1155/// the reason. It holds no text of a message.
1156#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1157pub struct Unanswered {
1158 pub session: SessionUri,
1159 pub reason: String,
1160}
1161
1162/// `POST /v1/items`: what a client saw of the items of its repository
1163/// on the forge (01M41FZP2C4Z4J6WKRXZ5B31EH). The server has no
1164/// credential of the forge: `riff pr open`, `riff verify`, `riff pr
1165/// wait` and the look of the lead send the facts. It is a signal. With
1166/// `all`, the facts replace each fact of the repository: an item with
1167/// no fact in the list has none now. Else each fact replaces only the
1168/// fact of its item.
1169#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1170pub struct ItemFacts {
1171 pub me: SessionUri,
1172 pub items: Vec<ItemFact>,
1173 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1174 pub all: bool,
1175}
1176
1177/// The facts of one item, for example `issue-12`.
1178///
1179/// ```
1180/// use riff_core::wire::{ItemFact, PullFact, PullState, Waits};
1181///
1182/// let fact = |state| ItemFact {
1183/// item: "issue-12".into(),
1184/// pull: Some(PullFact { number: 40, state }),
1185/// needs: vec![],
1186/// };
1187/// assert_eq!(fact(PullState::Asked).waits("issue-12"), Some(Waits::Verify { pull: 40 }));
1188/// assert_eq!(fact(PullState::Passed).waits("issue-12"), Some(Waits::Merge { pull: 40 }));
1189/// assert_eq!(fact(PullState::Passed).waits("verify-issue-12"), Some(Waits::Merge { pull: 40 }));
1190/// assert_eq!(fact(PullState::Asked).waits("verify-issue-12"), None, "the verifier works");
1191/// assert_eq!(fact(PullState::Failed).waits("issue-12"), None);
1192/// assert_eq!(fact(PullState::Merged).waits("issue-12"), None);
1193/// let needs = ItemFact { item: "issue-12".into(), pull: None, needs: vec![9] };
1194/// assert_eq!(needs.waits("issue-12"), Some(Waits::Needs { issues: vec![9] }));
1195/// assert_eq!(needs.waits("verify-issue-12"), None);
1196/// ```
1197#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1198pub struct ItemFact {
1199 pub item: String,
1200 /// The newest pull request of the item.
1201 #[serde(default, skip_serializing_if = "Option::is_none")]
1202 pub pull: Option<PullFact>,
1203 /// The open items of its `Needs:` line.
1204 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1205 pub needs: Vec<u64>,
1206}
1207
1208impl ItemFact {
1209 /// What a session that holds `claim` waits for, by this fact. The
1210 /// author waits for the verify and for the merge. The session that
1211 /// verifies works until its result, then waits for the merge.
1212 pub fn waits(&self, claim: &str) -> Option<Waits> {
1213 let verify = claim.starts_with("verify-");
1214 match self.pull.as_ref().map(|p| (p.number, p.state)) {
1215 Some((pull, PullState::Asked)) if !verify => Some(Waits::Verify { pull }),
1216 Some((pull, PullState::Passed)) => Some(Waits::Merge { pull }),
1217 _ if !verify && !self.needs.is_empty() => Some(Waits::Needs {
1218 issues: self.needs.clone(),
1219 }),
1220 _ => None,
1221 }
1222 }
1223}
1224
1225/// A pull request of an item, and its state.
1226#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1227pub struct PullFact {
1228 pub number: u64,
1229 pub state: PullState,
1230}
1231
1232/// The state of a pull request: from the status `riff/verify` of its
1233/// head, and from its merge.
1234#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1235#[serde(rename_all = "snake_case")]
1236pub enum PullState {
1237 /// Open, with no verify result: it waits for a verify.
1238 Asked,
1239 /// The verify passed: it waits for the merge.
1240 Passed,
1241 /// The verify failed: the item is free with its work.
1242 Failed,
1243 /// Merged.
1244 Merged,
1245}
1246
1247/// `POST /v1/threads`: lists the threads of `me`, with unread counts.
1248#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1249pub struct Threads {
1250 pub me: SessionUri,
1251}
1252
1253#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1254pub struct ThreadsReply {
1255 pub threads: Vec<ThreadInfo>,
1256}
1257
1258#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1259pub struct ThreadInfo {
1260 pub thread: ThreadName,
1261 pub members: Vec<SessionUri>,
1262 pub unread: usize,
1263}
1264
1265/// `POST /v1/join`: adds `me` to a thread. It makes the thread if it
1266/// is new.
1267#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1268pub struct Join {
1269 pub me: SessionUri,
1270 pub thread: ThreadName,
1271}
1272
1273/// `POST /v1/leave`: removes `me` from a thread. It is no longer the
1274/// lead there.
1275#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1276pub struct Leave {
1277 pub me: SessionUri,
1278 pub thread: ThreadName,
1279}
1280
1281/// `POST /v1/post`: adds a message to a thread and wakes each session
1282/// that `to` selects. The sender and each woken session join the
1283/// thread.
1284///
1285/// With no thread, the post is a direct message: `to` must be one
1286/// selector with a `session`, and the thread is the direct thread of
1287/// the two sessions.
1288///
1289/// A signed-in sender signs the post with [`Post::sign`] (R195).
1290///
1291/// ```
1292/// use riff_core::dpop::Key;
1293/// use riff_core::wire::Post;
1294///
1295/// let me = "riff://mike@pangolin/como-technologies/riff?session=a6cf".parse()?;
1296/// let mut post = Post::new(&me, Some("design".parse()?), vec![], "look");
1297/// let key = Key::generate();
1298/// post.sign(&key, 1_000);
1299/// let content = post.content().unwrap();
1300/// assert_eq!(content.verify(post.sig.as_ref().unwrap()).unwrap(), key.thumbprint());
1301/// # Ok::<(), riff_core::name::NameError>(())
1302/// ```
1303#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1304pub struct Post {
1305 pub me: SessionUri,
1306 #[serde(default)]
1307 pub thread: Option<ThreadName>,
1308 #[serde(default)]
1309 pub to: Vec<Selector>,
1310 pub body: String,
1311 #[serde(default, skip_serializing_if = "Kind::is_message")]
1312 pub kind: Kind,
1313 /// The signed time, in milliseconds since the Unix epoch.
1314 #[serde(default, skip_serializing_if = "Option::is_none")]
1315 pub at_ms: Option<u64>,
1316 /// The signature of the sender (see [`crate::signed`]).
1317 #[serde(default, skip_serializing_if = "Option::is_none")]
1318 pub sig: Option<String>,
1319 /// The bytes that `sig` signs: the base64url of the JSON of
1320 /// [`Content`]. The server and each reader keep them unchanged.
1321 #[serde(default, skip_serializing_if = "Option::is_none")]
1322 pub payload: Option<String>,
1323 /// The key of the news that the post tells. The signature does not
1324 /// cover it. The server drops a post whose key is the key of a kept
1325 /// message of the same user in the thread (01M5TKG2K1AYK1KE7C2AA2W9DS).
1326 #[serde(default, skip_serializing_if = "Option::is_none")]
1327 pub key: Option<String>,
1328}
1329
1330impl Post {
1331 /// A post of kind [`Kind::Message`], with no signature.
1332 pub fn new(me: &SessionUri, thread: Option<ThreadName>, to: Vec<Selector>, body: &str) -> Post {
1333 Post {
1334 me: me.clone(),
1335 thread,
1336 to,
1337 body: body.to_owned(),
1338 kind: Kind::Message,
1339 at_ms: None,
1340 sig: None,
1341 payload: None,
1342 key: None,
1343 }
1344 }
1345
1346 /// Signs the post with the device key `key`, at the time `at_ms`
1347 /// (R195, R196). The post carries the payload and the signature.
1348 pub fn sign(&mut self, key: &Key, at_ms: u64) {
1349 self.at_ms = Some(at_ms);
1350 self.payload = self.content().map(|content| content.payload());
1351 self.sig = self
1352 .payload
1353 .as_deref()
1354 .map(|payload| crate::signed::sign_payload(payload, key));
1355 }
1356
1357 /// What the signature covers. `None` when the post has no signed
1358 /// time.
1359 pub fn content(&self) -> Option<Content<'_>> {
1360 // A new field of a post must say here whether the signature
1361 // covers it.
1362 let Post {
1363 me,
1364 thread,
1365 to,
1366 body,
1367 kind,
1368 at_ms,
1369 sig: _,
1370 payload: _,
1371 key: _,
1372 } = self;
1373 Some(Content {
1374 from: me.who(),
1375 lead: me.lead(),
1376 thread: thread.as_ref(),
1377 to,
1378 body,
1379 kind: *kind,
1380 at_ms: (*at_ms)?,
1381 })
1382 }
1383}
1384
1385/// The kind of a post. A `post` call sends `message`, `status` or
1386/// `note`.
1387///
1388/// ```
1389/// use riff_core::wire::Kind;
1390///
1391/// assert_eq!(serde_json::to_string(&Kind::Status).unwrap(), r#""status""#);
1392/// assert_eq!("status".parse::<Kind>(), Ok(Kind::Status));
1393/// assert_eq!(serde_json::to_string(&Kind::Note).unwrap(), r#""note""#);
1394/// assert_eq!("note".parse::<Kind>(), Ok(Kind::Note));
1395/// assert!("other".parse::<Kind>().is_err());
1396/// assert!(Kind::Note.needs_body() && !Kind::Status.needs_body());
1397/// ```
1398#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, JsonSchema)]
1399#[serde(rename_all = "lowercase")]
1400pub enum Kind {
1401 /// A message to read.
1402 #[default]
1403 Message,
1404 /// A status request. Each session that it wakes sets its status
1405 /// with `status`. It does not post a reply.
1406 Status,
1407 /// A note: it informs and wakes no session. A session sees it at its
1408 /// next `read` (01M3JPMQE6S7YM4HPEVGXWK7ET).
1409 Note,
1410 /// A message that riff itself writes, as the person of the riff: the
1411 /// watch of a deploy, the daemon, the lines of `riff release`. It
1412 /// reads and wakes as a message. It never counts as the word of a
1413 /// person ([`Kind::by_riff`]). A session has no use for it, so a
1414 /// schema of a call does not list it.
1415 #[schemars(skip)]
1416 Riff,
1417 /// A kind that this build does not know. A reader shows the post as
1418 /// a message.
1419 #[schemars(skip)]
1420 Other,
1421}
1422
1423/// The set of kinds can grow. A build reads each value that it does not
1424/// know as [`Kind::Other`]: a text, and each other form of JSON
1425/// (01M3XSF90E9JYYTC13D9THY4WE). The kind [`Kind::Riff`] has a name in
1426/// JSON but not in [`FromStr`](std::str::FromStr): a person types no
1427/// such kind.
1428impl<'de> Deserialize<'de> for Kind {
1429 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1430 let value = serde_json::Value::deserialize(deserializer)?;
1431 Ok(match value.as_str() {
1432 Some("riff") => Kind::Riff,
1433 text => text
1434 .and_then(|text| text.parse().ok())
1435 .unwrap_or(Kind::Other),
1436 })
1437 }
1438}
1439
1440impl Kind {
1441 pub fn is_message(&self) -> bool {
1442 *self == Kind::Message
1443 }
1444
1445 /// True for the kind of a line that riff writes, not a person:
1446 /// [`Kind::Riff`].
1447 ///
1448 /// ```
1449 /// use riff_core::wire::Kind;
1450 ///
1451 /// assert!(Kind::Riff.by_riff());
1452 /// assert!(![Kind::Message, Kind::Status, Kind::Note, Kind::Other].iter().any(Kind::by_riff));
1453 /// ```
1454 pub fn by_riff(&self) -> bool {
1455 *self == Kind::Riff
1456 }
1457
1458 /// True when a post of this kind needs a body. Only a status request
1459 /// needs none.
1460 pub fn needs_body(&self) -> bool {
1461 *self != Kind::Status
1462 }
1463
1464 /// True for a kind that a `post` call can have. A kind of a later
1465 /// build reads as [`Kind::Other`], and no `post` call can have it
1466 /// (01M3XSF90E9JYYTC13D9THY4WE).
1467 ///
1468 /// ```
1469 /// use riff_core::wire::Kind;
1470 ///
1471 /// let read = |json: &str| serde_json::from_str::<Kind>(json).unwrap();
1472 /// assert_eq!(read(r#""note""#), Kind::Note);
1473 /// // The kind of a line of riff has a name in JSON, and none for a person.
1474 /// assert_eq!(read(r#""riff""#), Kind::Riff);
1475 /// assert_eq!(serde_json::to_string(&Kind::Riff).unwrap(), r#""riff""#);
1476 /// assert!("riff".parse::<Kind>().is_err());
1477 /// // A kind of a later build can have each form of JSON.
1478 /// for later in [r#""poll""#, r#"{"poll":"wave"}"#, "7", "null"] {
1479 /// assert_eq!(read(later), Kind::Other, "{later}");
1480 /// }
1481 /// assert!(Kind::Note.is_post() && Kind::Riff.is_post() && !Kind::Other.is_post());
1482 /// assert_eq!(serde_json::to_string(&Kind::Other).unwrap(), r#""other""#);
1483 ///
1484 /// // The schema of a call has only the kinds of a `post` call.
1485 /// let schema = serde_json::to_value(schemars::schema_for!(Kind)).unwrap();
1486 /// let kinds: Vec<&str> = schema["oneOf"]
1487 /// .as_array()
1488 /// .unwrap()
1489 /// .iter()
1490 /// .map(|kind| kind["const"].as_str().unwrap())
1491 /// .collect();
1492 /// assert_eq!(kinds, ["message", "status", "note"]);
1493 /// ```
1494 pub fn is_post(&self) -> bool {
1495 *self != Kind::Other
1496 }
1497}
1498
1499impl std::str::FromStr for Kind {
1500 type Err = String;
1501
1502 fn from_str(text: &str) -> Result<Self, Self::Err> {
1503 match text {
1504 "message" => Ok(Kind::Message),
1505 "status" => Ok(Kind::Status),
1506 "note" => Ok(Kind::Note),
1507 _ => Err(format!("no kind {text}: use message, status or note")),
1508 }
1509 }
1510}
1511
1512/// The reply to `post`. It tells the sender who woke.
1513#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1514pub struct Posted {
1515 pub thread: ThreadName,
1516 pub seq: u64,
1517 /// Each session that the message woke.
1518 #[serde(default)]
1519 pub woken: Vec<SessionUri>,
1520 /// Each selector that matched no session.
1521 #[serde(default)]
1522 pub unmatched: Vec<Selector>,
1523}
1524
1525/// `POST /v1/read`: returns the messages that `me` has not read, or
1526/// all of them when `all` is true. It marks them as read.
1527#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1528pub struct Read {
1529 pub me: SessionUri,
1530 pub thread: ThreadName,
1531 #[serde(default)]
1532 pub all: bool,
1533 /// With `all`: the page after this seq, from [`ReadReply::next`].
1534 #[serde(default, skip_serializing_if = "Option::is_none")]
1535 pub after: Option<u64>,
1536}
1537
1538#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1539pub struct ReadReply {
1540 /// At most one page of messages (01M3TBZBX140GJWCV5GZ73Q5Z5).
1541 pub messages: Vec<Message>,
1542 /// The seq of the last message of the page, when more messages
1543 /// follow. Read again for the next page: with `all`, set
1544 /// [`Read::after`] to it.
1545 #[serde(default, skip_serializing_if = "Option::is_none")]
1546 pub next: Option<u64>,
1547 /// In an unread read: each message after the page that woke `me`
1548 /// and that no read showed yet. The read marks it as seen, so the
1549 /// next watch does not wake for it (01M588J4XNV1TXEPMGTKRVY9VK).
1550 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1551 pub woken: Vec<Message>,
1552 /// The keys of the user of each sender, to verify the messages.
1553 #[serde(default, skip_serializing_if = "Keys::is_empty")]
1554 pub keys: Keys,
1555 /// True from a riff with no sign-in: it trusts each caller (R211).
1556 #[serde(default)]
1557 pub trusted: bool,
1558}
1559
1560/// The thumbprints of the device keys of each user, by user: the keys
1561/// of the live sign-ins of that user. A server without sign-in gives
1562/// none (R201).
1563pub type Keys = BTreeMap<String, Vec<String>>;
1564
1565#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1566pub struct Message {
1567 pub seq: u64,
1568 /// The URI of the sender when it posted.
1569 pub from: SessionUri,
1570 /// The address of the post.
1571 #[serde(default)]
1572 pub to: Vec<Selector>,
1573 pub body: String,
1574 /// Milliseconds since the Unix epoch. For a signed message, the
1575 /// signed time (R198).
1576 pub at_ms: u64,
1577 #[serde(default, skip_serializing_if = "Kind::is_message")]
1578 pub kind: Kind,
1579 /// The signature of the sender (see [`crate::signed`]).
1580 #[serde(default, skip_serializing_if = "Option::is_none")]
1581 pub sig: Option<String>,
1582 /// The bytes that `sig` signs, as the sender made them.
1583 #[serde(default, skip_serializing_if = "Option::is_none")]
1584 pub payload: Option<String>,
1585}
1586
1587impl Message {
1588 /// True when the sender of the message in `thread` is proven (R199):
1589 ///
1590 /// - The signature is valid over the kept payload, and the payload
1591 /// holds the message as it is: also the lead mark of the sender.
1592 /// - The key is one of `keys` for the user of the sender.
1593 /// - In a direct thread, the sender is one of the two sessions, and
1594 /// the one selector of the message matches the other session.
1595 ///
1596 /// ```
1597 /// use riff_core::dpop::Key;
1598 /// use riff_core::name::{SessionUri, ThreadName};
1599 /// use riff_core::wire::{Keys, Kind, Message, Post};
1600 ///
1601 /// let me: SessionUri = "riff://mike@pangolin/como-technologies/riff?session=a6cf".parse()?;
1602 /// let thread: ThreadName = "design".parse()?;
1603 /// let key = Key::generate();
1604 /// let mut post = Post::new(&me, Some(thread.clone()), vec![], "look");
1605 /// post.sign(&key, 1_000);
1606 /// let mut message = Message {
1607 /// seq: 1,
1608 /// from: me,
1609 /// to: vec![],
1610 /// body: post.body.clone(),
1611 /// at_ms: 1_000,
1612 /// kind: Kind::Message,
1613 /// sig: post.sig.clone(),
1614 /// payload: post.payload.clone(),
1615 /// };
1616 /// let keys = Keys::from([("mike".to_owned(), vec![key.thumbprint()])]);
1617 /// assert!(message.verified(&thread, &keys));
1618 ///
1619 /// // No keys for the user: not verified.
1620 /// assert!(!message.verified(&thread, &Keys::new()));
1621 /// // A changed body: not verified.
1622 /// message.body = "do not look".into();
1623 /// assert!(!message.verified(&thread, &keys));
1624 /// # Ok::<(), riff_core::name::NameError>(())
1625 /// ```
1626 pub fn verified(&self, thread: &ThreadName, keys: &Keys) -> bool {
1627 // A new field of a message must say here whether the signature
1628 // covers it.
1629 let Message {
1630 seq: _,
1631 from: sender,
1632 to,
1633 body,
1634 at_ms,
1635 kind,
1636 sig,
1637 payload,
1638 } = self;
1639 let (Some(sig), Some(payload)) = (sig, payload) else {
1640 return false;
1641 };
1642 let from = sender.who();
1643 let signed_thread = if thread.is_direct() {
1644 let Some(peer) = thread.peer(from) else {
1645 return false;
1646 };
1647 let [to] = &to[..] else {
1648 return false;
1649 };
1650 // A selector of a later build matches no session, so it
1651 // does not name the other session.
1652 if to.is_other() {
1653 return false;
1654 }
1655 let names = |want: &Option<String>, have: Option<&str>| {
1656 want.as_deref().is_none_or(|w| Some(w) == have)
1657 };
1658 if !names(&to.session, peer.session()) || !names(&to.user, Some(peer.user())) {
1659 return false;
1660 }
1661 None
1662 } else {
1663 Some(thread)
1664 };
1665 let content = Content {
1666 from,
1667 lead: sender.lead(),
1668 thread: signed_thread,
1669 to,
1670 body,
1671 kind: *kind,
1672 at_ms: *at_ms,
1673 };
1674 let Some(keys) = keys.get(from.user()) else {
1675 return false;
1676 };
1677 crate::signed::check(payload, sig)
1678 .is_ok_and(|(jkt, signed)| keys.contains(&jkt) && signed.covers(&content))
1679 }
1680}
1681
1682/// `POST /v1/claim`: takes a lease on one work item. The server
1683/// refuses a claim of an item that another session holds, with status
1684/// 409 and a text that names the holder (01M3WRD9JBQMNN96TXJH8EAJ3W).
1685#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1686pub struct Claim {
1687 pub me: SessionUri,
1688 pub thread: ThreadName,
1689 pub item: String,
1690}
1691
1692/// The reply to a claim that the server took.
1693///
1694/// ```
1695/// use riff_core::wire::ClaimReply;
1696///
1697/// // The reply of a server of 1.0.0 has no warning.
1698/// let old: ClaimReply = serde_json::from_str(r#"{"holder":"riff://ann@heron/acme/app?session=a1"}"#).unwrap();
1699/// assert_eq!(old.warning, None);
1700/// ```
1701#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1702pub struct ClaimReply {
1703 /// The URI of `me` now, with the item in its claims.
1704 pub holder: SessionUri,
1705 /// Why a worker would not get this claim, for example a hold of the
1706 /// item (01M43GSGPJ69TPWPA4935WR8RW). The server grants it, because
1707 /// the caller is not a worker.
1708 #[serde(default, skip_serializing_if = "Option::is_none")]
1709 pub warning: Option<String>,
1710}
1711
1712/// `POST /v1/plan/hold`: a lead holds an item of its repository thread
1713/// with a reason, so that no worker can claim it
1714/// (01M43GSGB9ZFHSG0Q83Y50FEGW). A hold of a held item replaces its
1715/// reason. Only a lead of the thread, the owner or an admin can
1716/// (01M43GSGGY0QMB5D5EH92M6ZFP).
1717///
1718/// ```
1719/// use riff_core::wire::{Call, Hold};
1720///
1721/// let hold = Hold {
1722/// me: "riff://mike@pangolin/como-technologies/riff?session=a1".parse()?,
1723/// thread: "como-technologies/riff".parse()?,
1724/// item: "issue-366".into(),
1725/// reason: "waits for the word of Mike".into(),
1726/// };
1727/// assert_eq!(Hold::PATH, "/v1/plan/hold");
1728/// assert!(serde_json::to_string(&hold).unwrap().contains(r#""reason":"waits for the word of Mike""#));
1729/// # Ok::<(), riff_core::name::NameError>(())
1730/// ```
1731#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1732pub struct Hold {
1733 pub me: SessionUri,
1734 /// The repository thread of the item.
1735 pub thread: ThreadName,
1736 pub item: String,
1737 /// Why the item is held: 1 to 200 characters.
1738 pub reason: String,
1739}
1740
1741/// The reply to a hold.
1742#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1743pub struct HoldReply {
1744 /// True when the call made the hold or changed its reason.
1745 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1746 pub changed: bool,
1747 /// The session that holds a claim of the item now. A hold does not
1748 /// end a claim: it stops only the next claim of a worker.
1749 #[serde(default, skip_serializing_if = "Option::is_none")]
1750 pub holder: Option<SessionUri>,
1751}
1752
1753/// `POST /v1/plan/free`: a lead ends the hold of an item
1754/// (01M43GSGB9ZFHSG0Q83Y50FEGW). A free of an item with no hold changes
1755/// nothing.
1756///
1757/// ```
1758/// use riff_core::wire::{Call, Free, FreeReply};
1759///
1760/// assert_eq!(Free::PATH, "/v1/plan/free");
1761/// assert_eq!(serde_json::to_string(&FreeReply::default()).unwrap(), "{}");
1762/// ```
1763#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1764pub struct Free {
1765 pub me: SessionUri,
1766 /// The repository thread of the item.
1767 pub thread: ThreadName,
1768 pub item: String,
1769}
1770
1771/// The reply to a free.
1772#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1773pub struct FreeReply {
1774 /// True when the item was held, and the call ended the hold.
1775 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1776 pub freed: bool,
1777}
1778
1779/// The hold of one item: its reason, and who held it and when: the `by`
1780/// and the time of its `item_held` record (01M43GSGB9ZFHSG0Q83Y50FEGW).
1781#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1782pub struct HoldInfo {
1783 pub reason: String,
1784 /// The caller of the hold. `None` when the record has no cause.
1785 #[serde(default, skip_serializing_if = "Option::is_none")]
1786 pub by: Option<By>,
1787 /// The time of the record, in milliseconds since the Unix epoch.
1788 pub at_ms: u64,
1789}
1790
1791/// `POST /v1/plan`: a session sends the full plan of its repository
1792/// thread (01M4A4YTNSJR0R1T9JNXPBSKHC). `base` is the position of the
1793/// `plan_set` record of the plan that the client compared with, or
1794/// `None` when the server had no plan. A `base` that is not the
1795/// position on the server gets the code `stale_base`
1796/// (01M4A4YTR2NKVBPE6BT9EC3X75).
1797///
1798/// ```
1799/// use riff_core::record::{Plan, PlanItem, PlanSet};
1800/// use riff_core::wire::{Call, SetPlan};
1801///
1802/// let plan = SetPlan {
1803/// me: "riff://mike@pangolin/como-technologies/riff?session=a1".parse()?,
1804/// base: Some(3001),
1805/// plan: PlanSet {
1806/// thread: "como-technologies/riff".parse()?,
1807/// plan: Plan {
1808/// wave: None,
1809/// items: vec![PlanItem { item: "issue-366".into(), needs: vec![] }],
1810/// done: vec![],
1811/// },
1812/// },
1813/// };
1814/// assert_eq!(SetPlan::PATH, "/v1/plan");
1815/// assert_eq!(
1816/// serde_json::to_string(&plan).unwrap(),
1817/// r#"{"me":"riff://mike@pangolin/como-technologies/riff?session=a1","base":3001,"plan":{"thread":"como-technologies/riff","items":[{"item":"issue-366","needs":[]}],"done":[]}}"#
1818/// );
1819/// # Ok::<(), riff_core::name::NameError>(())
1820/// ```
1821#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1822pub struct SetPlan {
1823 pub me: SessionUri,
1824 #[serde(default, skip_serializing_if = "Option::is_none")]
1825 pub base: Option<u64>,
1826 pub plan: PlanSet,
1827}
1828
1829/// `POST /v1/plan/off`: the server forgets the plan of the repository
1830/// thread. The holds stay (01M4A4YTNSJR0R1T9JNXPBSKHC).
1831#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1832pub struct PlanOff {
1833 pub me: SessionUri,
1834 pub thread: ThreadName,
1835}
1836
1837/// The reply to a `plan_off`.
1838#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1839pub struct PlanOffReply {
1840 /// True when the thread had a plan, and the call ended it.
1841 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1842 pub ended: bool,
1843}
1844
1845/// `POST /v1/plan/seen`: a look saw that the plan of the forge is the
1846/// plan of the server at `position` (01M4A4YTYVHFGK0CJVACJQ8DQ3). It is
1847/// a signal: it makes no record.
1848#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1849pub struct PlanSeen {
1850 pub me: SessionUri,
1851 pub thread: ThreadName,
1852 pub position: u64,
1853}
1854
1855/// `POST /v1/plan/show`: the plan of a repository thread, and its holds
1856/// (01M4A4Z1NKPDBXV2PRZCG86G6A).
1857#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1858pub struct PlanShow {
1859 pub me: SessionUri,
1860 pub thread: ThreadName,
1861}
1862
1863/// `POST /v1/notes/set`: the lead of a user writes the notes of its
1864/// repository thread (01M59258K3TYR09HC4AWHWZPEX). The text replaces
1865/// the old text, and the old text stays in the history. Only a session
1866/// of the user that is not a worker, and the person of the user, can
1867/// write them (01M59258NTXPMWV18TKRKWNTYN). The text has at most
1868/// [`NOTES_MAX`](crate::notes::NOTES_MAX) bytes, and it looks like no
1869/// token (01M59258RJ3CG498Z8AVQ5TV5Q).
1870///
1871/// ```
1872/// use riff_core::wire::{Call, SetNotes};
1873///
1874/// let set = SetNotes {
1875/// me: "riff://mike@pangolin/como-technologies/riff?session=a1".parse()?,
1876/// thread: "como-technologies/riff".parse()?,
1877/// text: "Prefer thelio for builds.".into(),
1878/// };
1879/// assert_eq!(SetNotes::PATH, "/v1/notes/set");
1880/// assert!(serde_json::to_string(&set).unwrap().contains(r#""text":"Prefer thelio for builds.""#));
1881/// # Ok::<(), riff_core::name::NameError>(())
1882/// ```
1883#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1884pub struct SetNotes {
1885 pub me: SessionUri,
1886 /// The repository thread of the notes.
1887 pub thread: ThreadName,
1888 pub text: String,
1889}
1890
1891/// The reply to [`SetNotes`].
1892#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1893pub struct SetNotesReply {
1894 /// True when the call made a new text. The same text makes no
1895 /// record.
1896 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1897 pub changed: bool,
1898}
1899
1900/// `POST /v1/notes`: the query for the notes of a lead
1901/// (01M59258NTXPMWV18TKRKWNTYN). The user of the lead can read them,
1902/// and the owner of the riff. Another member cannot.
1903///
1904/// ```
1905/// use riff_core::wire::{Call, NotesShow};
1906///
1907/// let show: NotesShow = serde_json::from_str(
1908/// r#"{"me":"riff://mike@pangolin/como-technologies/riff?session=a1","thread":"como-technologies/riff"}"#,
1909/// ).unwrap();
1910/// assert_eq!(show.user, None);
1911/// assert!(!show.history);
1912/// assert_eq!(NotesShow::PATH, "/v1/notes");
1913/// ```
1914#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
1915pub struct NotesShow {
1916 pub me: SessionUri,
1917 pub thread: ThreadName,
1918 /// The user of the lead. Leave it out to read the notes of the
1919 /// caller. Only the owner can name another user.
1920 #[serde(default, skip_serializing_if = "Option::is_none")]
1921 pub user: Option<String>,
1922 /// True asks also for the old texts.
1923 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1924 pub history: bool,
1925}
1926
1927/// One text of the notes, with who wrote it and when.
1928#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1929pub struct NotesVersion {
1930 pub text: String,
1931 /// The caller of the write. `None` when the record has no cause.
1932 #[serde(default, skip_serializing_if = "Option::is_none")]
1933 pub by: Option<By>,
1934 /// The time of the record, in milliseconds since the Unix epoch.
1935 pub at_ms: u64,
1936}
1937
1938/// The reply to [`NotesShow`]: the notes now, and with `history` the old
1939/// texts, the oldest first. A lead with no notes has an empty text.
1940///
1941/// ```
1942/// use riff_core::wire::NotesReply;
1943///
1944/// assert_eq!(serde_json::to_string(&NotesReply::default()).unwrap(), r#"{"user":"","text":""}"#);
1945/// ```
1946#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1947pub struct NotesReply {
1948 /// The user of the lead.
1949 pub user: String,
1950 pub text: String,
1951 /// Who wrote the text, and when. `None` when there are no notes.
1952 #[serde(default, skip_serializing_if = "Option::is_none")]
1953 pub by: Option<By>,
1954 #[serde(default, skip_serializing_if = "Option::is_none")]
1955 pub at_ms: Option<u64>,
1956 /// Each text before this one, the oldest first. Empty with no
1957 /// `history`.
1958 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1959 pub history: Vec<NotesVersion>,
1960}
1961
1962/// The plan of the server for one repository thread
1963/// (01M4A4Z1NKPDBXV2PRZCG86G6A): the reply of `plan`, `plan_seen` and
1964/// the query `plan`.
1965///
1966/// ```
1967/// use riff_core::wire::PlanReply;
1968///
1969/// // A thread with no plan and no hold.
1970/// assert_eq!(serde_json::to_string(&PlanReply::default()).unwrap(), "{}");
1971/// ```
1972#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1973pub struct PlanReply {
1974 /// The plan. `None` when the plan of the thread is off.
1975 #[serde(default, skip_serializing_if = "Option::is_none")]
1976 pub plan: Option<PlanShown>,
1977 /// Each hold of the thread, by its item: also of an item that is not
1978 /// in the plan.
1979 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1980 pub holds: BTreeMap<String, HoldInfo>,
1981}
1982
1983/// The plan of one repository thread on the server, with its age.
1984#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1985pub struct PlanShown {
1986 #[serde(flatten)]
1987 pub plan: Plan,
1988 /// The position of the `plan_set` record of the plan: the `base` of
1989 /// the next `plan`.
1990 pub position: u64,
1991 /// The time of the `plan_set` record, in milliseconds since the Unix
1992 /// epoch.
1993 pub set_ms: u64,
1994 /// The time of the last `plan` or `plan_seen`, in milliseconds since
1995 /// the Unix epoch. `None` when no look came since the start of the
1996 /// server.
1997 #[serde(default, skip_serializing_if = "Option::is_none")]
1998 pub seen_ms: Option<u64>,
1999 /// True when no look came for `PLAN_TTL` (01M4A4Z1QTHYXZDMCP9DZ39WVT).
2000 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2001 pub stale: bool,
2002 /// The session that holds a claim of each item of the plan with a
2003 /// claim.
2004 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2005 pub holders: BTreeMap<String, SessionUri>,
2006}
2007
2008/// `POST /v1/release`: frees a claim. Only its holder can.
2009#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
2010pub struct Release {
2011 pub me: SessionUri,
2012 pub thread: ThreadName,
2013 pub item: String,
2014}
2015
2016/// The reply to a release.
2017///
2018/// ```
2019/// use riff_core::wire::ReleaseReply;
2020///
2021/// assert_eq!(serde_json::to_string(&ReleaseReply::default()).unwrap(), "{}");
2022/// let last: ReleaseReply = serde_json::from_str(r#"{"must_clear":true}"#).unwrap();
2023/// assert!(last.must_clear);
2024/// ```
2025#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2026pub struct ReleaseReply {
2027 /// True when the release was the last claim of a worker: the worker
2028 /// must clear its context before its next claim (01M3X9XB37TQCXWPNFZRMRGJB4).
2029 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2030 pub must_clear: bool,
2031}
2032
2033/// `POST /v1/release/for`: the lead of a user frees the claim of
2034/// another session of that user (01M3WG243BW7P6E1ME0DFNQF8C). The
2035/// server posts a note to the thread of the claim.
2036#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
2037pub struct ReleaseFor {
2038 pub me: SessionUri,
2039 pub thread: ThreadName,
2040 pub item: String,
2041 /// The session that holds the item: its session ID, or the start of
2042 /// it.
2043 pub session: String,
2044}
2045
2046/// `POST /v1/lead`: the person `me` makes the session `session` the
2047/// lead of the user in the repository of `me`. It replaces the old
2048/// lead. A session cannot send it.
2049#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
2050pub struct Lead {
2051 pub me: SessionUri,
2052 /// The session ID of the new lead, or its start: at least 4
2053 /// characters.
2054 pub session: String,
2055}
2056
2057#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
2058pub struct LeadReply {
2059 /// The URI of the new lead now, with `lead=true`.
2060 pub lead: SessionUri,
2061 /// The old lead, when another session was the lead.
2062 #[serde(default)]
2063 pub replaced: Option<SessionUri>,
2064}
2065
2066/// `POST /v1/start`: a new start of the session: a new agent process,
2067/// a resume or a `/clear` (01M3JEE1QQCFS5TMZW5N2DAD2D). Its claims are free
2068/// at once. It keeps its ID, its threads and its lead. The call says
2069/// why the session starts, and if it is a worker (01M3X9X9M079WGFPJZHNXH9VEP).
2070///
2071/// ```
2072/// use riff_core::wire::{Start, StartReason};
2073///
2074/// let start: Start = serde_json::from_str(
2075/// r#"{"me":"riff://mike@pangolin/o/r?session=a1","reason":"clear","worker":true}"#,
2076/// ).unwrap();
2077/// assert_eq!(start.reason, StartReason::Clear);
2078/// assert!(start.worker);
2079/// ```
2080#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
2081pub struct Start {
2082 pub me: SessionUri,
2083 /// `process`, `resume` or `clear`.
2084 pub reason: StartReason,
2085 /// True when the session is a worker: `riff workers run` started it.
2086 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2087 pub worker: bool,
2088}
2089
2090/// Why a `session_started` record is there (01M3X9X9M079WGFPJZHNXH9VEP). A `start` call
2091/// sends `process`, `resume` or `clear`.
2092///
2093/// | Reason | Meaning | A fresh context |
2094/// |---|---|---|
2095/// | `process` | A new agent process. | Yes |
2096/// | `resume` | The agent process resumed an old conversation. | No |
2097/// | `clear` | The agent cleared its context. | Yes |
2098/// | `join` | The session came with no new start: a `register`. | No |
2099/// | `other` | A reason that this build does not know. | No |
2100///
2101/// ```
2102/// use riff_core::wire::StartReason;
2103///
2104/// let read = |json: &str| serde_json::from_str::<StartReason>(json).unwrap();
2105/// assert_eq!(read(r#""process""#), StartReason::Process);
2106/// assert_eq!(read(r#""wake""#), StartReason::Other);
2107/// // A reason of a later build can have each form of JSON.
2108/// for later in [r#"{"wake":"timer"}"#, "7", "null", r#"["clear"]"#] {
2109/// assert_eq!(read(later), StartReason::Other, "{later}");
2110/// }
2111/// assert!(StartReason::Clear.is_fresh() && StartReason::Process.is_fresh());
2112/// assert!(!StartReason::Resume.is_fresh() && !StartReason::Join.is_fresh());
2113/// assert!(StartReason::Resume.is_start() && !StartReason::Join.is_start());
2114/// assert_eq!(StartReason::Clear.word(), "clear");
2115/// ```
2116#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
2117#[serde(rename_all = "snake_case")]
2118pub enum StartReason {
2119 Process,
2120 Resume,
2121 Clear,
2122 Join,
2123 /// A reason that this build does not know.
2124 Other,
2125}
2126
2127/// The set of reasons can grow. A build reads each value that it does
2128/// not know as [`StartReason::Other`]: a text, and each other form of
2129/// JSON (01M3XM2C18TT8VSKGD77YPZG53).
2130impl<'de> Deserialize<'de> for StartReason {
2131 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2132 let value = serde_json::Value::deserialize(deserializer)?;
2133 let known = [
2134 StartReason::Process,
2135 StartReason::Resume,
2136 StartReason::Clear,
2137 StartReason::Join,
2138 ];
2139 Ok(known
2140 .into_iter()
2141 .find(|reason| value.as_str() == Some(reason.word()))
2142 .unwrap_or(StartReason::Other))
2143 }
2144}
2145
2146impl StartReason {
2147 /// True for a start with a fresh context: it ends MustClear.
2148 pub fn is_fresh(self) -> bool {
2149 matches!(self, StartReason::Process | StartReason::Clear)
2150 }
2151
2152 /// True for a reason that a `start` call can have.
2153 pub fn is_start(self) -> bool {
2154 matches!(
2155 self,
2156 StartReason::Process | StartReason::Resume | StartReason::Clear
2157 )
2158 }
2159
2160 /// The word of the reason, as the JSON has it.
2161 pub fn word(self) -> &'static str {
2162 match self {
2163 StartReason::Process => "process",
2164 StartReason::Resume => "resume",
2165 StartReason::Clear => "clear",
2166 StartReason::Join => "join",
2167 StartReason::Other => "other",
2168 }
2169 }
2170}
2171
2172#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
2173pub struct Started {
2174 /// Each claim that the start freed.
2175 #[serde(default)]
2176 pub freed: Vec<Freed>,
2177}
2178
2179/// A claim that a new start freed.
2180#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2181pub struct Freed {
2182 pub thread: ThreadName,
2183 pub item: String,
2184}
2185
2186/// `POST /v1/riff`: reads the pauses of the riff, for the place of
2187/// `me`. It changes nothing (01M3WRD9BSBKS9TN66H29TGTBV).
2188#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
2189pub struct RiffQuery {
2190 pub me: SessionUri,
2191}
2192
2193/// `POST /v1/pause`: pauses the repository of `me`, or with `riff` the
2194/// whole riff (01M3XAHZBGSSJB3YX23K88W01K). See
2195/// 01M3XAHZDSQR263QZVB41CK0MX for who can.
2196///
2197/// ```
2198/// use riff_core::wire::Pause;
2199///
2200/// let me = "riff://mike@pangolin/como-technologies/riff".parse()?;
2201/// let pause = Pause::here(me);
2202/// assert_eq!(
2203/// serde_json::to_string(&pause).unwrap(),
2204/// r#"{"me":"riff://mike@pangolin/como-technologies/riff"}"#
2205/// );
2206/// let json = serde_json::to_string(&Pause::whole("riff://mike@pangolin".parse()?)).unwrap();
2207/// assert_eq!(json, r#"{"me":"riff://mike@pangolin","riff":true}"#);
2208/// let whole: Pause = serde_json::from_str(r#"{"me":"riff://mike@pangolin","riff":true}"#).unwrap();
2209/// assert!(whole.riff);
2210/// # Ok::<(), riff_core::name::NameError>(())
2211/// ```
2212#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
2213pub struct Pause {
2214 pub me: SessionUri,
2215 /// True pauses the whole riff, not the repository of `me`.
2216 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2217 pub riff: bool,
2218 /// Pauses this repository, not the repository of `me`.
2219 #[serde(default, skip_serializing_if = "Option::is_none")]
2220 pub repository: Option<ThreadName>,
2221}
2222
2223impl Pause {
2224 /// The pause of the repository of `me`.
2225 pub fn here(me: SessionUri) -> Pause {
2226 Pause {
2227 me,
2228 riff: false,
2229 repository: None,
2230 }
2231 }
2232
2233 /// The pause of the whole riff.
2234 pub fn whole(me: SessionUri) -> Pause {
2235 Pause {
2236 riff: true,
2237 ..Pause::here(me)
2238 }
2239 }
2240}
2241
2242/// `POST /v1/resume`: resumes the repository of `me`, or with `riff`
2243/// the whole riff (01M3XAHZBGSSJB3YX23K88W01K). See
2244/// 01M3XAHZDSQR263QZVB41CK0MX for who can.
2245#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
2246pub struct Resume {
2247 pub me: SessionUri,
2248 /// True resumes the whole riff, not the repository of `me`.
2249 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2250 pub riff: bool,
2251 /// Resumes this repository, not the repository of `me`.
2252 #[serde(default, skip_serializing_if = "Option::is_none")]
2253 pub repository: Option<ThreadName>,
2254}
2255
2256impl Resume {
2257 /// The resume of the repository of `me`.
2258 pub fn here(me: SessionUri) -> Resume {
2259 Resume {
2260 me,
2261 riff: false,
2262 repository: None,
2263 }
2264 }
2265
2266 /// The resume of the whole riff.
2267 pub fn whole(me: SessionUri) -> Resume {
2268 Resume {
2269 riff: true,
2270 ..Resume::here(me)
2271 }
2272 }
2273}
2274
2275/// The pauses of the riff, as the caller sees them
2276/// (01M3XAHZJAF6YVDJ7WX74X8RBX): the reply to `/v1/riff`, `/v1/pause`
2277/// and `/v1/resume`.
2278///
2279/// ```
2280/// use riff_core::wire::{RiffReply, RiffState};
2281///
2282/// // The reply of a server from before the pause of a repository.
2283/// let old: RiffReply = serde_json::from_str(r#"{"state":"paused"}"#).unwrap();
2284/// assert_eq!(old.state, RiffState::Paused);
2285/// assert!(old.riff.is_none() && old.repositories.is_empty());
2286/// ```
2287#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2288pub struct RiffReply {
2289 /// The state for the place of the caller: paused when the whole
2290 /// riff is paused, or when the repository of the caller is paused.
2291 pub state: RiffState,
2292 /// True when the call changed a pause.
2293 #[serde(default)]
2294 pub changed: bool,
2295 /// The pause of the whole riff. `None` when the riff runs.
2296 #[serde(default, skip_serializing_if = "Option::is_none")]
2297 pub riff: Option<PauseInfo>,
2298 /// Each repository that is paused, in the order of the names.
2299 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2300 pub repositories: Vec<RepositoryPause>,
2301}
2302
2303/// The pauses of a riff with no pause of a repository: `paused` is a
2304/// pause of the whole riff that no caller set.
2305///
2306/// ```
2307/// use riff_core::wire::{RiffReply, RiffState};
2308///
2309/// let paused = RiffReply::from(RiffState::Paused);
2310/// assert!(paused.riff.is_some());
2311/// assert!(RiffReply::from(RiffState::Running).riff.is_none());
2312/// ```
2313impl From<RiffState> for RiffReply {
2314 fn from(state: RiffState) -> RiffReply {
2315 RiffReply {
2316 state,
2317 changed: false,
2318 riff: (state == RiffState::Paused).then(PauseInfo::default),
2319 repositories: Vec::new(),
2320 }
2321 }
2322}
2323
2324impl RiffReply {
2325 /// The pause of the repository `thread`, when it is paused.
2326 pub fn repository(&self, thread: &ThreadName) -> Option<&PauseInfo> {
2327 self.repositories
2328 .iter()
2329 .find(|r| &r.repository == thread)
2330 .map(|r| &r.pause)
2331 }
2332}
2333
2334/// A pause that holds: who set it, and when
2335/// (01M3XAHZBGSSJB3YX23K88W01K).
2336///
2337/// ```
2338/// use riff_core::record::By;
2339/// use riff_core::wire::PauseInfo;
2340///
2341/// let pause = PauseInfo { by: Some(By::Person("mike".into())), at_ms: 7 };
2342/// let json = r#"{"by":{"person":"mike"},"at_ms":7}"#;
2343/// assert_eq!(serde_json::to_string(&pause).unwrap(), json);
2344/// assert_eq!(serde_json::from_str::<PauseInfo>(json).unwrap(), pause);
2345/// // The pause of a new riff: no caller set it.
2346/// assert_eq!(serde_json::to_string(&PauseInfo::default()).unwrap(), r#"{"at_ms":0}"#);
2347/// ```
2348#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2349pub struct PauseInfo {
2350 /// The caller that set the pause. `None` when it is not known: the
2351 /// pause of a new riff, or of a record with no cause.
2352 #[serde(default, skip_serializing_if = "Option::is_none")]
2353 pub by: Option<By>,
2354 /// The time of the pause, in milliseconds since the Unix epoch.
2355 #[serde(default)]
2356 pub at_ms: u64,
2357}
2358
2359/// A repository that is paused, with its pause.
2360#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2361pub struct RepositoryPause {
2362 pub repository: ThreadName,
2363 #[serde(flatten)]
2364 pub pause: PauseInfo,
2365}
2366
2367/// `POST /v1/idle`: reads the settings of idle workers
2368/// (01M3Q5A0TF9K49V8Z1ZY9NDF74). It changes nothing
2369/// (01M3WRD9BSBKS9TN66H29TGTBV).
2370#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
2371pub struct IdleQuery {
2372 pub me: SessionUri,
2373}
2374
2375/// `POST /v1/idle/set`: sets each given setting of idle workers, and
2376/// gives the settings. Only the owner or an admin can; in a riff with
2377/// no sign-in, each caller can (01M3Q5A0TF9K49V8Z1ZY9NDF74).
2378#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
2379pub struct SetIdle {
2380 pub me: SessionUri,
2381 #[serde(default, skip_serializing_if = "Option::is_none")]
2382 pub per_host: Option<u16>,
2383 #[serde(default, skip_serializing_if = "Option::is_none")]
2384 pub after_secs: Option<u64>,
2385}
2386
2387/// The settings of idle workers (01M3Q5A0TF9K49V8Z1ZY9NDF74). The server
2388/// stops no worker for them: the log and the checkpoint keep them so
2389/// that an old record still reads.
2390///
2391/// ```
2392/// use riff_core::wire::Idle;
2393///
2394/// let idle = Idle::default();
2395/// assert_eq!((idle.per_host, idle.after_secs), (1, 60));
2396/// ```
2397#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2398pub struct Idle {
2399 pub per_host: u16,
2400 pub after_secs: u64,
2401}
2402
2403impl Default for Idle {
2404 fn default() -> Self {
2405 Idle {
2406 per_host: 1,
2407 after_secs: 60,
2408 }
2409 }
2410}
2411
2412/// The state of a riff. A new riff is paused.
2413///
2414/// ```
2415/// use riff_core::wire::RiffState;
2416///
2417/// assert_eq!(RiffState::default(), RiffState::Paused);
2418/// assert_eq!(serde_json::to_string(&RiffState::Running).unwrap(), r#""running""#);
2419/// assert_eq!(RiffState::Running.to_string(), "running");
2420/// ```
2421#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2422#[serde(rename_all = "lowercase")]
2423pub enum RiffState {
2424 /// The sessions stop at their next step and wait. Nobody claims.
2425 #[default]
2426 Paused,
2427 /// The sessions pick and do work.
2428 Running,
2429}
2430
2431impl std::fmt::Display for RiffState {
2432 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2433 f.write_str(match self {
2434 RiffState::Paused => "paused",
2435 RiffState::Running => "running",
2436 })
2437 }
2438}
2439
2440/// An event on `GET /v1/watch?uri=…`: a message addressed to the
2441/// session.
2442#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2443pub struct Wake {
2444 pub thread: ThreadName,
2445 pub seq: u64,
2446 pub from: SessionUri,
2447 #[serde(default, skip_serializing_if = "Kind::is_message")]
2448 pub kind: Kind,
2449}
2450
2451/// An event on `GET /v1/tail?thread=…`.
2452#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2453pub struct Tailed {
2454 pub thread: ThreadName,
2455 pub message: Message,
2456 /// The keys of the user of the sender, to verify the message.
2457 #[serde(default, skip_serializing_if = "Keys::is_empty")]
2458 pub keys: Keys,
2459 /// True from a riff with no sign-in (R211). See
2460 /// [`ReadReply::trusted`].
2461 #[serde(default)]
2462 pub trusted: bool,
2463}
2464
2465/// The grant type that swaps an ID token of the sign-in provider for
2466/// a first pair of riff tokens (RFC 8693 token exchange).
2467pub const TOKEN_EXCHANGE: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
2468
2469/// The subject token type of a [`TOKEN_EXCHANGE`] request.
2470pub const ID_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:id_token";
2471
2472/// The subject token type of a [`TOKEN_EXCHANGE`] request that swaps a
2473/// person access token for a session access token (R19).
2474pub const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token";
2475
2476/// `POST /v1/token`, as `application/x-www-form-urlencoded`.
2477///
2478/// | `grant_type` | Fields |
2479/// |---|---|
2480/// | `refresh_token` | `refresh_token` |
2481/// | [`TOKEN_EXCHANGE`] | `subject_token` (an ID token), `subject_token_type` = [`ID_TOKEN_TYPE`] |
2482/// | [`TOKEN_EXCHANGE`] | `subject_token` (a person access token), `subject_token_type` = [`ACCESS_TOKEN_TYPE`], `session` |
2483#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
2484pub struct TokenRequest {
2485 pub grant_type: String,
2486 #[serde(default, skip_serializing_if = "Option::is_none")]
2487 pub refresh_token: Option<String>,
2488 #[serde(default, skip_serializing_if = "Option::is_none")]
2489 pub subject_token: Option<String>,
2490 #[serde(default, skip_serializing_if = "Option::is_none")]
2491 pub subject_token_type: Option<String>,
2492 /// The session ID of a session token.
2493 #[serde(default, skip_serializing_if = "Option::is_none")]
2494 pub session: Option<String>,
2495 /// The server that the token is for (RFC 8707). When it is set, it
2496 /// must be the public URL of the server.
2497 #[serde(default, skip_serializing_if = "Option::is_none")]
2498 pub resource: Option<String>,
2499}
2500
2501/// A new pair of riff tokens. Send `access_token` with the `DPoP`
2502/// scheme and a proof from the device key. Use `refresh_token` once, to
2503/// get the next pair.
2504///
2505/// The reply to a swap for a session token has an empty
2506/// `refresh_token`: a session token has none
2507/// (01M52JYGWD3ZJKNYE3HG5BJYTP).
2508///
2509/// ```
2510/// use riff_core::wire::TokenReply;
2511///
2512/// let json = r#"{"access_token":"a","token_type":"DPoP","expires_in":600,
2513/// "refresh_token":"","user":"mike"}"#;
2514/// let session: TokenReply = serde_json::from_str(json).unwrap();
2515/// assert!(session.refresh_token.is_empty());
2516/// ```
2517#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2518pub struct TokenReply {
2519 pub access_token: String,
2520 /// Always `DPoP`.
2521 pub token_type: String,
2522 /// Seconds until the access token expires.
2523 pub expires_in: u64,
2524 /// Empty for a session token.
2525 pub refresh_token: String,
2526 /// The user part of the session URI, from the sign-in (R36).
2527 pub user: String,
2528}
2529
2530/// `GET /v1/sign-in`: the OpenID Connect provider that `riff login`
2531/// signs in with.
2532#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2533pub struct SignInConfig {
2534 /// The issuer. Its discovery document is at
2535 /// `<issuer>/.well-known/openid-configuration`.
2536 pub issuer: String,
2537 pub client_id: String,
2538 /// Google asks for the secret of a desktop client too. It is not a
2539 /// secret: it ships to each person who signs in.
2540 #[serde(default, skip_serializing_if = "Option::is_none")]
2541 pub client_secret: Option<String>,
2542 /// The ID of this riff (01M3JNVBPMZ1K9WX7Q7DP6Y0DH). `riff` keeps it
2543 /// with its sign-in. Another ID means that the riff is new.
2544 pub riff_id: String,
2545}
2546
2547/// The fields that riff uses from the discovery document of an OpenID
2548/// Connect provider.
2549#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2550pub struct Discovery {
2551 pub issuer: String,
2552 pub authorization_endpoint: String,
2553 pub token_endpoint: String,
2554 pub jwks_uri: String,
2555}
2556
2557impl Discovery {
2558 /// The URL of the discovery document of `issuer`.
2559 ///
2560 /// ```
2561 /// use riff_core::wire::Discovery;
2562 ///
2563 /// assert_eq!(
2564 /// Discovery::url("https://accounts.google.com/"),
2565 /// "https://accounts.google.com/.well-known/openid-configuration"
2566 /// );
2567 /// ```
2568 pub fn url(issuer: &str) -> String {
2569 format!(
2570 "{}/.well-known/openid-configuration",
2571 issuer.trim_end_matches('/')
2572 )
2573 }
2574}
2575
2576/// `POST /v1/revoke`: ends each sign-in of a person.
2577#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2578pub struct Revoke {
2579 /// The person. Leave it out for the caller. Only an admin names
2580 /// another person.
2581 #[serde(default, skip_serializing_if = "Option::is_none")]
2582 pub user: Option<String>,
2583}
2584
2585/// The reply to [`Revoke`].
2586#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2587pub struct Revoked {
2588 pub user: String,
2589 /// The number of sign-ins that ended.
2590 pub sign_ins: usize,
2591}
2592
2593/// `POST /v1/invite`: adds a member, by verified email.
2594#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2595pub struct Invite {
2596 pub email: String,
2597}
2598
2599/// The reply to [`Invite`].
2600#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2601pub struct Invited {
2602 /// The email of the member, in lower case.
2603 pub email: String,
2604 /// The public address of the riff: the `--public-url` of the
2605 /// server. The member puts it in `RIFF_SERVER`.
2606 pub address: String,
2607}
2608
2609/// `POST /v1/remove`: removes a member and ends each sign-in of that
2610/// person.
2611#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2612pub struct Remove {
2613 pub email: String,
2614}
2615
2616/// The reply to [`Remove`].
2617#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2618pub struct Removed {
2619 /// The email, in lower case.
2620 pub email: String,
2621 /// The number of sign-ins that ended.
2622 pub sign_ins: usize,
2623}
2624
2625/// `POST /v1/admin`: the owner makes a person an admin, or an admin a
2626/// member again.
2627///
2628/// ```
2629/// use riff_core::wire::SetAdmin;
2630///
2631/// let add = SetAdmin { email: "bob@gmail.com".into(), admin: true };
2632/// assert_eq!(
2633/// serde_json::to_string(&add).unwrap(),
2634/// r#"{"email":"bob@gmail.com","admin":true}"#
2635/// );
2636/// ```
2637#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2638pub struct SetAdmin {
2639 pub email: String,
2640 /// True makes the person an admin. False makes an admin a member
2641 /// again.
2642 pub admin: bool,
2643}
2644
2645/// The reply to [`SetAdmin`]: the change of its record, with the email
2646/// in lower case.
2647pub use crate::record::AdminSet;
2648
2649/// `POST /v1/owner`: the owner passes the owner role to a member or an
2650/// admin. The old owner stays an admin.
2651///
2652/// ```
2653/// use riff_core::wire::PassOwner;
2654///
2655/// let pass = PassOwner { email: "bob@gmail.com".into() };
2656/// assert_eq!(serde_json::to_string(&pass).unwrap(), r#"{"email":"bob@gmail.com"}"#);
2657/// ```
2658#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2659pub struct PassOwner {
2660 pub email: String,
2661}
2662
2663/// The reply to [`PassOwner`].
2664#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2665pub struct OwnerPassed {
2666 /// The email of the new owner, in lower case.
2667 pub owner: String,
2668 /// The email of the old owner, now an admin.
2669 pub admin: String,
2670}
2671
2672/// `POST /v1/owner/take`: an admin asks for the owner role
2673/// (01M3N7K3ZAZFGABN7032AYJWEM).
2674#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2675pub struct TakeOwner {}
2676
2677/// The reply to [`TakeOwner`].
2678///
2679/// ```
2680/// use riff_core::wire::OwnerAsked;
2681///
2682/// let asked: OwnerAsked = serde_json::from_str(
2683/// r#"{"admin":"bob@gmail.com","owner":"ada@gmail.com","answer_secs":600}"#,
2684/// ).unwrap();
2685/// assert_eq!(asked.owner.as_deref(), Some("ada@gmail.com"));
2686/// assert!(!asked.already());
2687///
2688/// // The owner asked: nothing changed (01M3WRJAFS6W3J2ZRJ6XSW3SB5).
2689/// let same: OwnerAsked = serde_json::from_str(
2690/// r#"{"admin":"ada@gmail.com","owner":"ada@gmail.com","answer_secs":0}"#,
2691/// ).unwrap();
2692/// assert!(same.already());
2693/// ```
2694#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2695pub struct OwnerAsked {
2696 /// The email of the admin that asked, in lower case.
2697 pub admin: String,
2698 /// The email of the owner that answers. `None` when the riff had no
2699 /// owner: the admin is the owner now. The email of `admin` when the
2700 /// admin that asked is the owner already: nothing changed.
2701 pub owner: Option<String>,
2702 /// The time that the owner has to answer, in seconds. With no
2703 /// answer, the admin is the owner.
2704 pub answer_secs: u64,
2705}
2706
2707impl OwnerAsked {
2708 /// True when the admin that asked is the owner already
2709 /// (01M3WRJAFS6W3J2ZRJ6XSW3SB5).
2710 pub fn already(&self) -> bool {
2711 self.owner.as_deref() == Some(self.admin.as_str())
2712 }
2713}
2714
2715/// `POST /v1/owner/deny`: the owner keeps the owner role that an admin
2716/// asks for (01M3N7K41N03P26BEFFNX5617K).
2717#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2718pub struct DenyOwner {}
2719
2720/// The reply to [`DenyOwner`].
2721#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2722pub struct OwnerDenied {
2723 /// The email of the owner, who stays the owner.
2724 pub owner: String,
2725 /// The email of the admin that asked.
2726 pub admin: String,
2727}
2728
2729/// `POST /v1/log`: the records of the log of one repository, for an
2730/// audit (01M3ZWRC11R5M9V1KTF05P240W). Only the owner and the admins
2731/// can read them.
2732///
2733/// ```
2734/// use riff_core::wire::{Call, LogQuery};
2735///
2736/// let query = LogQuery { repo: "acme/app".parse()? };
2737/// assert_eq!(serde_json::to_string(&query).unwrap(), r#"{"repo":"acme/app"}"#);
2738/// assert_eq!(LogQuery::PATH, "/v1/log");
2739/// # Ok::<(), riff_core::name::NameError>(())
2740/// ```
2741#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2742pub struct LogQuery {
2743 /// The repository thread, for example `acme/app`.
2744 pub repo: ThreadName,
2745}
2746
2747/// The reply to [`LogQuery`]: each record of the repository
2748/// ([`Record::of_repository`]), in log order. A post has only its mark
2749/// ([`Record::for_audit`], 01M3ZWRC3XBFN8FJDGE8XWZ5EA).
2750///
2751/// A reader skips a record of a kind that its build does not know, as
2752/// [`Line::parse`] does (01M43GSMZKCMET3DG07K538EDD). So `riff audit`
2753/// of one build reads the log of a later server.
2754///
2755/// ```
2756/// use riff_core::wire::LogReply;
2757///
2758/// let reply: LogReply = serde_json::from_str(r#"{"records":[
2759/// {"position":1,"written_at_ms":1,"change":{"reacted":{"emoji":"+1"}}},
2760/// {"position":2,"written_at_ms":1,"change":{"member_invited":{"email":"ann@acme.io"}}}
2761/// ]}"#).unwrap();
2762/// assert_eq!(reply.records.len(), 1);
2763/// assert_eq!(reply.records[0].envelope.position, 2);
2764/// ```
2765#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
2766pub struct LogReply {
2767 #[serde(deserialize_with = "known_records")]
2768 pub records: Vec<Record>,
2769 /// The records that the log no longer keeps: `None` when it keeps
2770 /// each record from position 1 (01M588N3R2VP12A8X4EASX0BJ4).
2771 #[serde(default, skip_serializing_if = "Option::is_none")]
2772 pub pruned: Option<Pruned>,
2773}
2774
2775/// The start of the log after the checkpoint rule removed its oldest
2776/// chunks: the reply of `POST /v1/log` has the records from `first`
2777/// on (01M588N3R2VP12A8X4EASX0BJ4).
2778///
2779/// ```
2780/// use riff_core::wire::{LogReply, Pruned};
2781///
2782/// let reply: LogReply = serde_json::from_str(
2783/// r#"{"records":[],"pruned":{"first":1918,"first_ms":5000}}"#,
2784/// ).unwrap();
2785/// assert_eq!(reply.pruned, Some(Pruned { first: 1918, first_ms: 5000 }));
2786/// ```
2787#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2788pub struct Pruned {
2789 /// The position of the oldest record that the log keeps.
2790 pub first: u64,
2791 /// When the oldest record of a known kind that the log keeps was
2792 /// written, in milliseconds since the Unix epoch. A record before it
2793 /// can be gone.
2794 pub first_ms: u64,
2795}
2796
2797/// The records of a [`LogReply`] with a kind that this build knows.
2798/// A record that does not read is an error.
2799fn known_records<'de, D>(deserializer: D) -> Result<Vec<Record>, D::Error>
2800where
2801 D: serde::Deserializer<'de>,
2802{
2803 let values = Vec::<serde_json::Value>::deserialize(deserializer)?;
2804 let mut records = Vec::with_capacity(values.len());
2805 for value in values {
2806 match Line::parse(&value.to_string()).map_err(serde::de::Error::custom)? {
2807 Line::Record(record) => records.push(*record),
2808 Line::Unknown { .. } => {}
2809 }
2810 }
2811 Ok(records)
2812}
2813
2814/// `POST /v1/members`: shows who may join the riff.
2815#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2816pub struct Members {}
2817
2818/// The reply to [`Members`].
2819///
2820/// ```
2821/// use riff_core::wire::MembersReply;
2822///
2823/// let reply: MembersReply = serde_json::from_str(
2824/// r#"{"owner":"ada@gmail.com","admins":[],"members":["bob@gmail.com"],"allowed_domains":["x.io"]}"#,
2825/// ).unwrap();
2826/// assert_eq!(reply.owner.as_deref(), Some("ada@gmail.com"));
2827/// ```
2828#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2829pub struct MembersReply {
2830 /// The email of the owner. `None` when the riff has no owner: before
2831 /// the first sign-in, or after the owner was gone
2832 /// (01M3Q63NNC6SC03BFCG80M7B4D).
2833 pub owner: Option<String>,
2834 /// The email of each admin, sorted: the admins that the owner made
2835 /// and the admins of the settings (R210). The owner is not in it.
2836 pub admins: Vec<String>,
2837 /// The email of each member that is not the owner and not an admin,
2838 /// sorted (01M3MN157X8N9QKER1AJEPEJVX).
2839 pub members: Vec<String>,
2840 /// The allowed domains (R15).
2841 pub allowed_domains: Vec<String>,
2842}
2843
2844/// An OAuth error reply, for example `{"error":"invalid_grant"}`.
2845#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2846pub struct TokenError {
2847 pub error: String,
2848 /// What a person must read, for a refusal that only they can fix,
2849 /// for example a USER that another account holds (R209).
2850 #[serde(default, skip_serializing_if = "Option::is_none")]
2851 pub error_description: Option<String>,
2852}
2853
2854/// The protected resource metadata of `riff-server` (RFC 9728).
2855#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2856pub struct ResourceMetadata {
2857 /// The public URL of the server. Tokens are only for it.
2858 pub resource: String,
2859 /// The issuer of each token. It is the server itself.
2860 pub authorization_servers: Vec<String>,
2861 /// Always `["header"]`: a token goes only in the Authorization header.
2862 pub bearer_methods_supported: Vec<String>,
2863 /// Always `["ES256"]` (RFC 9449).
2864 pub dpop_signing_alg_values_supported: Vec<String>,
2865 /// Always true: each token is bound to a device key (R18).
2866 pub dpop_bound_access_tokens_required: bool,
2867}
2868
2869/// The authorization server metadata of `riff-server` (RFC 8414).
2870#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2871pub struct ServerMetadata {
2872 pub issuer: String,
2873 pub token_endpoint: String,
2874 pub grant_types_supported: Vec<String>,
2875 /// Empty: the server has no authorization endpoint (R83).
2876 pub response_types_supported: Vec<String>,
2877 pub code_challenge_methods_supported: Vec<String>,
2878 /// Always `["none"]`: `riff` is a public client.
2879 pub token_endpoint_auth_methods_supported: Vec<String>,
2880 /// Always `["ES256"]` (RFC 9449).
2881 pub dpop_signing_alg_values_supported: Vec<String>,
2882}
2883
2884#[cfg(test)]
2885mod tests {
2886 use super::*;
2887 use crate::name::Who;
2888
2889 const MIKE: &str = "riff://mike@pangolin/como-technologies/riff?session=a6cf";
2890 const BRETT: &str = "riff://brett@heron/como-technologies/riff?session=77e0";
2891
2892 fn uri(text: &str) -> SessionUri {
2893 text.parse().unwrap()
2894 }
2895
2896 /// The message that the server stores for a signed post.
2897 fn stored(post: &Post, from: SessionUri) -> Message {
2898 Message {
2899 seq: 1,
2900 from,
2901 to: post.to.clone(),
2902 body: post.body.clone(),
2903 at_ms: post.at_ms.unwrap(),
2904 kind: post.kind,
2905 sig: post.sig.clone(),
2906 payload: post.payload.clone(),
2907 }
2908 }
2909
2910 fn keys(user: &str, key: &Key) -> Keys {
2911 Keys::from([(user.to_owned(), vec![key.thumbprint()])])
2912 }
2913
2914 #[test]
2915 fn a_direct_message_verifies_only_in_its_own_thread() {
2916 let key = Key::generate();
2917 let (mike, brett) = (uri(MIKE), uri(BRETT));
2918 let to = vec![Selector::session("77e0")];
2919 let mut post = Post::new(&mike, None, to, "hi");
2920 post.sign(&key, 5);
2921 let message = stored(&post, mike.clone());
2922 let keys = keys("mike", &key);
2923
2924 let theirs = ThreadName::direct(mike.who(), brett.who());
2925 assert!(message.verified(&theirs, &keys));
2926
2927 // The same message in the direct thread of mike and another session.
2928 let other = Who::new("ada", Some("c3")).unwrap();
2929 assert!(!message.verified(&ThreadName::direct(mike.who(), &other), &keys));
2930 // In a direct thread without mike.
2931 assert!(!message.verified(&ThreadName::direct(brett.who(), &other), &keys));
2932 // In a named thread.
2933 assert!(!message.verified(&"design".parse().unwrap(), &keys));
2934 }
2935
2936 #[test]
2937 fn a_direct_message_to_the_lead_must_reach_a_session_of_that_user() {
2938 let key = Key::generate();
2939 let mike = uri(MIKE);
2940 let lead = Who::new("mike", Some("1ead")).unwrap();
2941 let to = vec![Selector::lead("mike", "como-technologies/riff")];
2942 let mut post = Post::new(&mike, None, to, "may I?");
2943 post.sign(&key, 5);
2944 let message = stored(&post, mike.clone());
2945 let keys = keys("mike", &key);
2946 assert!(message.verified(&ThreadName::direct(mike.who(), &lead), &keys));
2947 let brett = Who::new("brett", Some("77e0")).unwrap();
2948 assert!(!message.verified(&ThreadName::direct(mike.who(), &brett), &keys));
2949 }
2950
2951 #[test]
2952 fn a_false_sender_is_not_verified() {
2953 let key = Key::generate();
2954 let thread: ThreadName = "design".parse().unwrap();
2955 let mut post = Post::new(&uri(MIKE), Some(thread.clone()), vec![], "go");
2956 post.sign(&key, 5);
2957 let keys = keys("mike", &key);
2958 assert!(stored(&post, uri(MIKE)).verified(&thread, &keys));
2959
2960 // The stored sender names the lead: another session of mike.
2961 let lead = uri("riff://mike@pangolin/como-technologies/riff?session=1ead&lead=true");
2962 assert!(!stored(&post, lead).verified(&thread, &keys));
2963 // The key of mike does not count for brett.
2964 assert!(!stored(&post, uri(BRETT)).verified(&thread, &keys));
2965 }
2966
2967 #[test]
2968 fn the_place_and_the_claims_are_not_signed() {
2969 let key = Key::generate();
2970 let thread: ThreadName = "design".parse().unwrap();
2971 let mut post = Post::new(&uri(MIKE), Some(thread.clone()), vec![], "go");
2972 post.sign(&key, 5);
2973 let now = "riff://mike@pangolin/other/repo?session=a6cf&claim=issue-6#api";
2974 assert!(stored(&post, uri(now)).verified(&thread, &keys("mike", &key)));
2975 }
2976
2977 #[test]
2978 fn the_lead_mark_is_signed() {
2979 let key = Key::generate();
2980 let keys = keys("mike", &key);
2981 let thread: ThreadName = "design".parse().unwrap();
2982 let lead = uri(&format!("{MIKE}&lead=true"));
2983
2984 // A lead mark added to the sender after the post.
2985 let mut post = Post::new(&uri(MIKE), Some(thread.clone()), vec![], "merge now");
2986 post.sign(&key, 5);
2987 assert!(stored(&post, uri(MIKE)).verified(&thread, &keys));
2988 assert!(!stored(&post, lead.clone()).verified(&thread, &keys));
2989
2990 // A post of the lead: its lead mark is signed.
2991 let mut post = Post::new(&lead, Some(thread.clone()), vec![], "merge now");
2992 post.sign(&key, 5);
2993 assert!(stored(&post, lead).verified(&thread, &keys));
2994 assert!(!stored(&post, uri(MIKE)).verified(&thread, &keys));
2995 }
2996
2997 #[test]
2998 fn a_message_without_a_signature_is_not_verified() {
2999 let key = Key::generate();
3000 let thread: ThreadName = "design".parse().unwrap();
3001 let message = Message {
3002 seq: 1,
3003 from: uri(MIKE),
3004 to: vec![],
3005 body: "go".into(),
3006 at_ms: 5,
3007 kind: Kind::Message,
3008 sig: None,
3009 payload: None,
3010 };
3011 assert!(!message.verified(&thread, &keys("mike", &key)));
3012 }
3013
3014 #[test]
3015 fn a_signed_post_carries_the_payload_that_it_signs() {
3016 let key = Key::generate();
3017 let thread: ThreadName = "design".parse().unwrap();
3018 let mut post = Post::new(&uri(MIKE), Some(thread.clone()), vec![], "go");
3019 post.sign(&key, 5);
3020 let payload = post.payload.clone().unwrap();
3021 assert_eq!(payload, post.content().unwrap().payload());
3022 let (jkt, signed) = crate::signed::check(&payload, post.sig.as_ref().unwrap()).unwrap();
3023 assert_eq!(jkt, key.thumbprint());
3024 assert!(signed.covers(&post.content().unwrap()));
3025 }
3026
3027 #[test]
3028 fn a_message_needs_its_kept_payload() {
3029 let key = Key::generate();
3030 let thread: ThreadName = "design".parse().unwrap();
3031 let keys = keys("mike", &key);
3032 let mut post = Post::new(&uri(MIKE), Some(thread.clone()), vec![], "go");
3033 post.sign(&key, 5);
3034
3035 let mut message = stored(&post, uri(MIKE));
3036 message.payload = None;
3037 assert!(!message.verified(&thread, &keys), "no payload");
3038
3039 // The payload of another message, with its own signature.
3040 let mut other = Post::new(&uri(MIKE), Some(thread.clone()), vec![], "stop");
3041 other.sign(&key, 5);
3042 let mut message = stored(&post, uri(MIKE));
3043 message.payload = other.payload.clone();
3044 message.sig = other.sig.clone();
3045 assert!(
3046 !message.verified(&thread, &keys),
3047 "the payload of another body"
3048 );
3049 }
3050
3051 #[test]
3052 fn a_new_field_in_the_payload_does_not_stop_the_check() {
3053 use base64::Engine;
3054 let key = Key::generate();
3055 let thread: ThreadName = "design".parse().unwrap();
3056 let mut post = Post::new(&uri(MIKE), Some(thread.clone()), vec![], "go");
3057 post.sign(&key, 5);
3058 // A later build adds `reply_to` to the payload, and signs it.
3059 let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
3060 let mut json: serde_json::Value =
3061 serde_json::from_slice(&b64.decode(post.payload.as_ref().unwrap()).unwrap()).unwrap();
3062 json["reply_to"] = 7.into();
3063 let payload = b64.encode(serde_json::to_vec(&json).unwrap());
3064 let mut message = stored(&post, uri(MIKE));
3065 message.sig = Some(crate::signed::sign_payload(&payload, &key));
3066 message.payload = Some(payload);
3067 assert!(message.verified(&thread, &keys("mike", &key)));
3068 }
3069}