Skip to main content

igc_net/
follow.rs

1//! Follow/unfollow record types.
2//!
3//! Implements the follow schemas described in `specs/70-groups-and-social.md §6`.
4
5use serde::{Deserialize, Serialize};
6
7use crate::id::{Blake3Hex, PilotId};
8use crate::util::{canonical_utc_now, is_canonical_utc_timestamp};
9
10const FOLLOW_SCHEMA: &str = "igc-net/follow";
11const FOLLOW_VERSION: u8 = 1;
12const UNFOLLOW_SCHEMA: &str = "igc-net/unfollow";
13const UNFOLLOW_VERSION: u8 = 1;
14
15// ── Error ─────────────────────────────────────────────────────────────────────
16
17#[derive(Debug, thiserror::Error)]
18pub enum FollowRecordError {
19    #[error("JSON: {0}")]
20    Json(#[from] serde_json::Error),
21    #[error("identifier: {0}")]
22    Identifier(#[from] crate::id::IdentifierError),
23    #[error("schema must be {expected:?}, got {found:?}")]
24    Schema { expected: &'static str, found: String },
25    #[error("schema_version must be {expected}, got {found}")]
26    SchemaVersion { expected: u8, found: u8 },
27    #[error("created_at is not canonical UTC RFC3339 seconds format: {0:?}")]
28    CreatedAt(String),
29    #[error("signature must be 128 lowercase hex chars")]
30    SignatureEncoding,
31    #[error("pilot_id does not contain a valid Ed25519 public key: {0}")]
32    PilotIdPublicKey(String),
33    #[error("record_id mismatch: expected {expected}, found {found}")]
34    RecordIdMismatch {
35        expected: Blake3Hex,
36        found: Blake3Hex,
37    },
38    #[error("signature verification failed")]
39    SignatureVerification,
40    #[error("follower and followee must be distinct pilot IDs")]
41    SelfFollow,
42}
43
44// ── FollowRecord ──────────────────────────────────────────────────────────────
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct FollowRecord {
48    pub schema: String,
49    pub schema_version: u8,
50    pub record_id: Blake3Hex,
51    pub follower_pilot_id: PilotId,
52    pub followee_pilot_id: PilotId,
53    pub created_at: String,
54    pub signature: String,
55}
56
57#[derive(Serialize)]
58struct FollowPayload<'a> {
59    schema: &'static str,
60    schema_version: u8,
61    follower_pilot_id: &'a PilotId,
62    followee_pilot_id: &'a PilotId,
63    created_at: &'a str,
64}
65
66#[derive(Serialize)]
67struct FollowSignPayload<'a> {
68    schema: &'static str,
69    schema_version: u8,
70    record_id: &'a Blake3Hex,
71    follower_pilot_id: &'a PilotId,
72    followee_pilot_id: &'a PilotId,
73    created_at: &'a str,
74}
75
76impl FollowRecord {
77    pub fn issue(
78        follower_secret_key: &iroh::SecretKey,
79        followee_pilot_id: PilotId,
80    ) -> Result<Self, FollowRecordError> {
81        let created_at = canonical_utc_now();
82        let follower_pilot_id = PilotId::from_public_key(follower_secret_key.public());
83        if follower_pilot_id == followee_pilot_id {
84            return Err(FollowRecordError::SelfFollow);
85        }
86
87        let id_payload = FollowPayload {
88            schema: FOLLOW_SCHEMA,
89            schema_version: FOLLOW_VERSION,
90            follower_pilot_id: &follower_pilot_id,
91            followee_pilot_id: &followee_pilot_id,
92            created_at: &created_at,
93        };
94        let record_id = blake3_record_id(&id_payload)?;
95        let sign_payload = FollowSignPayload {
96            schema: FOLLOW_SCHEMA,
97            schema_version: FOLLOW_VERSION,
98            record_id: &record_id,
99            follower_pilot_id: &follower_pilot_id,
100            followee_pilot_id: &followee_pilot_id,
101            created_at: &created_at,
102        };
103        let signature = sign_payload_hex(follower_secret_key, &sign_payload)?;
104
105        let record = Self {
106            schema: FOLLOW_SCHEMA.to_string(),
107            schema_version: FOLLOW_VERSION,
108            record_id,
109            follower_pilot_id,
110            followee_pilot_id,
111            created_at,
112            signature,
113        };
114        record.validate()?;
115        Ok(record)
116    }
117
118    pub fn validate(&self) -> Result<(), FollowRecordError> {
119        check_schema(&self.schema, FOLLOW_SCHEMA)?;
120        check_schema_version(self.schema_version, FOLLOW_VERSION)?;
121        check_created_at(&self.created_at)?;
122        if self.follower_pilot_id == self.followee_pilot_id {
123            return Err(FollowRecordError::SelfFollow);
124        }
125
126        let id_payload = FollowPayload {
127            schema: FOLLOW_SCHEMA,
128            schema_version: FOLLOW_VERSION,
129            follower_pilot_id: &self.follower_pilot_id,
130            followee_pilot_id: &self.followee_pilot_id,
131            created_at: &self.created_at,
132        };
133        let expected = blake3_record_id(&id_payload)?;
134        if self.record_id != expected {
135            return Err(FollowRecordError::RecordIdMismatch { expected, found: self.record_id.clone() });
136        }
137        let sign_payload = FollowSignPayload {
138            schema: FOLLOW_SCHEMA,
139            schema_version: FOLLOW_VERSION,
140            record_id: &self.record_id,
141            follower_pilot_id: &self.follower_pilot_id,
142            followee_pilot_id: &self.followee_pilot_id,
143            created_at: &self.created_at,
144        };
145        verify_signature(&self.follower_pilot_id, &self.signature, &sign_payload)?;
146        Ok(())
147    }
148}
149
150// ── UnfollowRecord ────────────────────────────────────────────────────────────
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153pub struct UnfollowRecord {
154    pub schema: String,
155    pub schema_version: u8,
156    pub record_id: Blake3Hex,
157    pub follower_pilot_id: PilotId,
158    pub followee_pilot_id: PilotId,
159    pub created_at: String,
160    pub signature: String,
161}
162
163#[derive(Serialize)]
164struct UnfollowPayload<'a> {
165    schema: &'static str,
166    schema_version: u8,
167    follower_pilot_id: &'a PilotId,
168    followee_pilot_id: &'a PilotId,
169    created_at: &'a str,
170}
171
172#[derive(Serialize)]
173struct UnfollowSignPayload<'a> {
174    schema: &'static str,
175    schema_version: u8,
176    record_id: &'a Blake3Hex,
177    follower_pilot_id: &'a PilotId,
178    followee_pilot_id: &'a PilotId,
179    created_at: &'a str,
180}
181
182impl UnfollowRecord {
183    pub fn issue(
184        follower_secret_key: &iroh::SecretKey,
185        followee_pilot_id: PilotId,
186    ) -> Result<Self, FollowRecordError> {
187        let created_at = canonical_utc_now();
188        let follower_pilot_id = PilotId::from_public_key(follower_secret_key.public());
189        if follower_pilot_id == followee_pilot_id {
190            return Err(FollowRecordError::SelfFollow);
191        }
192
193        let id_payload = UnfollowPayload {
194            schema: UNFOLLOW_SCHEMA,
195            schema_version: UNFOLLOW_VERSION,
196            follower_pilot_id: &follower_pilot_id,
197            followee_pilot_id: &followee_pilot_id,
198            created_at: &created_at,
199        };
200        let record_id = blake3_record_id(&id_payload)?;
201        let sign_payload = UnfollowSignPayload {
202            schema: UNFOLLOW_SCHEMA,
203            schema_version: UNFOLLOW_VERSION,
204            record_id: &record_id,
205            follower_pilot_id: &follower_pilot_id,
206            followee_pilot_id: &followee_pilot_id,
207            created_at: &created_at,
208        };
209        let signature = sign_payload_hex(follower_secret_key, &sign_payload)?;
210
211        let record = Self {
212            schema: UNFOLLOW_SCHEMA.to_string(),
213            schema_version: UNFOLLOW_VERSION,
214            record_id,
215            follower_pilot_id,
216            followee_pilot_id,
217            created_at,
218            signature,
219        };
220        record.validate()?;
221        Ok(record)
222    }
223
224    pub fn validate(&self) -> Result<(), FollowRecordError> {
225        check_schema(&self.schema, UNFOLLOW_SCHEMA)?;
226        check_schema_version(self.schema_version, UNFOLLOW_VERSION)?;
227        check_created_at(&self.created_at)?;
228        if self.follower_pilot_id == self.followee_pilot_id {
229            return Err(FollowRecordError::SelfFollow);
230        }
231
232        let id_payload = UnfollowPayload {
233            schema: UNFOLLOW_SCHEMA,
234            schema_version: UNFOLLOW_VERSION,
235            follower_pilot_id: &self.follower_pilot_id,
236            followee_pilot_id: &self.followee_pilot_id,
237            created_at: &self.created_at,
238        };
239        let expected = blake3_record_id(&id_payload)?;
240        if self.record_id != expected {
241            return Err(FollowRecordError::RecordIdMismatch { expected, found: self.record_id.clone() });
242        }
243        let sign_payload = UnfollowSignPayload {
244            schema: UNFOLLOW_SCHEMA,
245            schema_version: UNFOLLOW_VERSION,
246            record_id: &self.record_id,
247            follower_pilot_id: &self.follower_pilot_id,
248            followee_pilot_id: &self.followee_pilot_id,
249            created_at: &self.created_at,
250        };
251        verify_signature(&self.follower_pilot_id, &self.signature, &sign_payload)?;
252        Ok(())
253    }
254}
255
256// ── Shared helpers ────────────────────────────────────────────────────────────
257
258fn blake3_record_id<T: serde::Serialize>(payload: &T) -> Result<Blake3Hex, FollowRecordError> {
259    let bytes = json_canon::to_vec(payload)?;
260    Ok(Blake3Hex::from_hash(blake3::hash(&bytes)))
261}
262
263fn sign_payload_hex<T: serde::Serialize>(
264    secret_key: &iroh::SecretKey,
265    payload: &T,
266) -> Result<String, FollowRecordError> {
267    let bytes = json_canon::to_vec(payload)?;
268    Ok(hex::encode(secret_key.sign(&bytes).to_bytes()))
269}
270
271fn pilot_id_public_key(pilot_id: &PilotId) -> Result<iroh::PublicKey, FollowRecordError> {
272    let bytes = hex::decode(pilot_id.public_key_hex())
273        .ok()
274        .and_then(|b| <[u8; 32]>::try_from(b).ok())
275        .ok_or_else(|| FollowRecordError::PilotIdPublicKey(pilot_id.to_string()))?;
276    iroh::PublicKey::from_bytes(&bytes)
277        .map_err(|_| FollowRecordError::PilotIdPublicKey(pilot_id.to_string()))
278}
279
280fn decode_signature_hex(value: &str) -> Result<iroh::Signature, FollowRecordError> {
281    if value.len() != 128 || !value.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
282        return Err(FollowRecordError::SignatureEncoding);
283    }
284    let bytes = hex::decode(value).map_err(|_| FollowRecordError::SignatureEncoding)?;
285    let sig_bytes: [u8; 64] = bytes
286        .try_into()
287        .map_err(|_| FollowRecordError::SignatureEncoding)?;
288    Ok(iroh::Signature::from_bytes(&sig_bytes))
289}
290
291fn verify_signature<T: serde::Serialize>(
292    signer: &PilotId,
293    signature_hex: &str,
294    payload: &T,
295) -> Result<(), FollowRecordError> {
296    let pubkey = pilot_id_public_key(signer)?;
297    let signature = decode_signature_hex(signature_hex)?;
298    let bytes = json_canon::to_vec(payload)?;
299    pubkey
300        .verify(&bytes, &signature)
301        .map_err(|_| FollowRecordError::SignatureVerification)
302}
303
304fn check_schema(found: &str, expected: &'static str) -> Result<(), FollowRecordError> {
305    if found != expected {
306        return Err(FollowRecordError::Schema { expected, found: found.to_string() });
307    }
308    Ok(())
309}
310
311fn check_schema_version(found: u8, expected: u8) -> Result<(), FollowRecordError> {
312    if found != expected {
313        return Err(FollowRecordError::SchemaVersion { expected, found });
314    }
315    Ok(())
316}
317
318fn check_created_at(value: &str) -> Result<(), FollowRecordError> {
319    if !is_canonical_utc_timestamp(value) {
320        return Err(FollowRecordError::CreatedAt(value.to_string()));
321    }
322    Ok(())
323}