recall_wire/lib.rs
1//! The contract between Recall's client and server: request and response
2//! shapes, and the validation rules that apply to both.
3//!
4//! Keeping this in one crate is the point of a workspace rather than two
5//! programs. Before the implementations were unified, `file_path`
6//! validation and the tombstone/empty-file distinction existed twice — in
7//! JavaScript on the server and in bash on the client — with nothing
8//! keeping them in agreement, so a drift between them would only surface in
9//! production.
10//!
11//! # Layout
12//!
13//! One module per endpoint, since that is the unit that has to stay
14//! compatible. Every type is also re-exported at the crate root, so callers
15//! can write `recall_wire::PushRequest` and never think about which endpoint
16//! a type belongs to.
17//!
18//! | Module | Endpoint |
19//! |---|---|
20//! | [`sync`] | `POST /sync`, `GET /sync` — memory files in both directions |
21//! | [`health`] | `GET /health` — unauthenticated liveness and merge status |
22//! | [`admin`] | `GET /admin/stats` — what is stored, per project |
23//! | [`discovery`] | `GET /.well-known/recall` — what the server is and speaks |
24//! | [`devices`] | `/v1/devices`, `/v1/authkeys` — enrolling and managing devices |
25//! | [`signature`] | not an endpoint: how a device signs every request |
26//! | [`validate`] | the rules both halves enforce |
27//!
28//! # Frozen surface
29//!
30//! **These JSON shapes are frozen.** The deployed Node server speaks them,
31//! its SQLite rows were written against them, and during any migration a
32//! machine on the old client and one on the new binary talk to the same
33//! deployment. Field names and ordering here are compatibility surface, not
34//! style.
35//!
36//! That includes timestamps, which every `updated_at`, `checked_at` and
37//! `last_*_at` field carries as JavaScript's `Date.toISOString()` —
38//! millisecond precision with a `Z` suffix, e.g. `2026-09-03T21:49:55.191Z`.
39//! Rows already in the database are in that shape. `recall_server::now`
40//! produces it.
41//!
42//! The full reference, including status codes and worked `curl` examples,
43//! is in `docs/reference/api.md`.
44
45#![deny(missing_docs)]
46
47pub mod admin;
48pub mod devices;
49pub mod discovery;
50pub mod health;
51pub mod signature;
52pub mod sync;
53pub mod validate;
54
55pub use admin::{AdminStats, AdminTotals, ProjectStats};
56pub use devices::{
57 ApproveRequest, Authkey, AuthkeyCreated, AuthkeyList, AuthkeyRequest, AuthkeyRevokeRequest,
58 DenyRequest, DenyResponse, Device, DeviceIdentity, DeviceList, DevicesCapability,
59 EnrollApproved, EnrollPending, EnrollPollRequest, EnrollPollResponse, EnrollRequest,
60 PendingEnrollment,
61};
62pub use discovery::{Discovery, DISCOVERY_PATH, PROTOCOL, PROTOCOL_HEADER};
63pub use health::{ClaudeCliStatus, Health, MergeError, MergeStatus};
64/// The hash a push names its base by: SHA-256 of the file's exact bytes, as
65/// lowercase hex.
66///
67/// Computed on both sides of the wire — by the client over what it last
68/// synced, by the server over what it has stored — so it has to be one
69/// function in one place.
70pub fn content_sha256(content: &str) -> String {
71 use sha2::{Digest, Sha256};
72 let digest = Sha256::digest(content.as_bytes());
73 digest.iter().map(|b| format!("{b:02x}")).collect()
74}
75
76#[cfg(test)]
77mod hash_tests {
78 use super::content_sha256;
79
80 /// Known SHA-256 vectors, so the two sides of the wire cannot drift into
81 /// computing something else under the same name.
82 #[test]
83 fn content_sha256_is_plain_sha256_as_lowercase_hex() {
84 assert_eq!(
85 content_sha256(""),
86 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
87 );
88 assert_eq!(
89 content_sha256("abc"),
90 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
91 );
92 }
93
94 /// Exact bytes: a trailing newline is part of the content, and so part of
95 /// the hash.
96 #[test]
97 fn a_trailing_newline_changes_the_hash() {
98 assert_ne!(content_sha256("a"), content_sha256("a\n"));
99 }
100}
101
102pub use sync::{File, PushRequest, PushResponse, SyncResponse};
103pub use validate::{validate_file_path, ValidationError};
104
105use serde::{Deserialize, Serialize};
106
107/// Body returned for any non-2xx, on every endpoint.
108///
109/// It lives at the root rather than in an endpoint module because it belongs
110/// to all of them: whatever a request was trying to do, this is the shape of
111/// being told no.
112#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
113pub struct ErrorResponse {
114 /// A human-readable reason, safe to show a user.
115 pub error: String,
116}