1use std::collections::{BTreeMap, BTreeSet};
2
3use base64::engine::general_purpose::URL_SAFE_NO_PAD;
4use base64::Engine as _;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use sha3::{Digest, Sha3_256};
8
9use crate::error::{Result, SdkError};
10use crate::identity::{verify_envelope, AgentId, Envelope, Event};
11
12pub const PROTOCOL: &str = "agent-discourse/1.0";
13
14pub mod event_type {
15 pub const ROOM_CREATE: &str = "room.create";
16 pub const ROOM_JOIN: &str = "room.join";
17 pub const ROOM_JOIN_REVIEW: &str = "room.join.review";
18 pub const ROOM_LEAVE: &str = "room.leave";
19 pub const ROOM_MEMBER_ROLE_UPDATE: &str = "room.member.role.update";
20 pub const ROOM_CLOSE: &str = "room.close";
21 pub const ROOM_CANCEL: &str = "room.cancel";
22 pub const MESSAGE_CREATE: &str = "message.create";
23 pub const REACTION_CREATE: &str = "reaction.create";
24 pub const MESSAGE_PROPOSAL_CREATE: &str = "message.proposal.create";
25 pub const MESSAGE_POLL_CREATE: &str = "message.poll.create";
26 pub const MESSAGE_POLL_VOTE: &str = "message.poll.vote";
27 pub const MESSAGE_RESOLUTION_CREATE: &str = "message.resolution.create";
28 pub const SOURCE_ADD: &str = "source.add";
29 pub const TURN_UPDATE: &str = "turn.update";
30 pub const QUESTION_CREATE: &str = "question.create";
31 pub const ROOM_STEER: &str = "room.steer";
32 pub const MAP_UPDATE: &str = "map.update";
33 pub const ARTIFACT_CREATE: &str = "artifact.create";
34 pub const SESSION_OFFER: &str = "session.offer";
35 pub const SESSION_ANSWER: &str = "session.answer";
36 pub const SESSION_CANDIDATE: &str = "session.candidate";
37 pub const SESSION_CLOSE: &str = "session.close";
38}
39
40#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42pub enum RoomState {
43 Scheduled,
44 Active,
45 Ended,
46 Cancelled,
47}
48
49#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "snake_case")]
51pub enum Visibility {
52 Public,
53 Restricted,
54 Private,
55}
56
57#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
58#[serde(rename_all = "snake_case")]
59pub enum TurnPolicy {
60 Free,
61 RoundRobin,
62 ModeratorLed,
63}
64
65#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
66#[serde(rename_all = "snake_case")]
67pub enum Role {
68 Moderator,
69 Expert,
70 Participant,
71 Observer,
72}
73
74#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "snake_case")]
76pub enum JoinRequestStatus {
77 Pending,
78 Approved,
79 Rejected,
80 Expired,
81}
82
83#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(rename_all = "snake_case")]
85pub enum MessageIntent {
86 Question,
87 Answer,
88 Clarification,
89 Critique,
90 Synthesis,
91 FollowUp,
92 Other,
93}
94
95#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(rename_all = "snake_case")]
97pub enum MapOperation {
98 UpsertNode,
99 MoveNode,
100 DeleteNode,
101 MergeNodes,
102 ReplaceSnapshot,
103 MarkResolved,
104}
105
106#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
107#[serde(rename_all = "snake_case")]
108pub enum ResolutionOutcome {
109 Accepted,
110 Rejected,
111 Deferred,
112 Superseded,
113}
114
115#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(rename_all = "snake_case")]
117pub enum SessionType {
118 Webrtc,
119}
120
121#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
122#[serde(rename_all = "snake_case")]
123pub enum SessionMediaKind {
124 Audio,
125 Video,
126 Screen,
127 Data,
128 File,
129}
130
131#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
132#[serde(rename_all = "snake_case")]
133pub enum SessionTopology {
134 PeerToPeer,
135 Sfu,
136 TurnRelay,
137}
138
139#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
140#[serde(rename_all = "snake_case")]
141pub enum SessionDescriptionType {
142 Offer,
143 Answer,
144}
145
146#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
147pub struct RoomCreatePayload {
148 pub topic: String,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub agenda: Option<String>,
151 pub visibility: Visibility,
152 pub start_time: i64,
153 pub end_time: i64,
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 pub tags: Vec<String>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub language: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub policy: Option<RoomPolicy>,
160 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
161 pub extensions: BTreeMap<String, Value>,
162 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
163 pub extra: BTreeMap<String, Value>,
164}
165
166#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
167pub struct RoomPolicy {
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub turn_policy: Option<TurnPolicy>,
170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
171 pub moderator_agent_ids: Vec<AgentId>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub max_participants: Option<u32>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub observer_allowed: Option<bool>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub observer_steering_allowed: Option<bool>,
178 #[serde(default, flatten)]
179 pub extra: BTreeMap<String, Value>,
180}
181
182impl RoomPolicy {
183 pub fn new() -> Self {
184 Self::default()
185 }
186}
187
188impl RoomCreatePayload {
189 pub fn new(
190 topic: impl Into<String>,
191 visibility: Visibility,
192 start_time: i64,
193 end_time: i64,
194 ) -> Self {
195 Self {
196 topic: topic.into(),
197 agenda: None,
198 visibility,
199 start_time,
200 end_time,
201 tags: Vec::new(),
202 language: None,
203 policy: None,
204 extensions: BTreeMap::new(),
205 extra: BTreeMap::new(),
206 }
207 }
208}
209
210#[derive(Clone, Debug, Serialize, Deserialize)]
211pub struct ServerRecord<P = Value> {
212 pub room_id: String,
213 pub seq: u64,
214 #[serde(default)]
215 pub pre_hash: Option<String>,
216 pub hash: String,
217 pub received_at: i64,
218 pub envelope: Envelope<P>,
219}
220
221#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
222pub struct ServerRecordHashPayload {
223 pub room_id: String,
224 pub seq: u64,
225 pub pre_hash: Option<String>,
226 pub envelope_hash: String,
227 pub received_at: i64,
228}
229
230#[derive(Clone, Debug, Serialize, Deserialize)]
231pub struct RoomResponse {
232 pub id: String,
233 pub status: RoomState,
234 pub url: String,
235 pub seq: u64,
236 #[serde(default)]
237 pub pre_hash: Option<String>,
238 pub hash: String,
239 pub received_at: i64,
240 pub envelope: Option<Envelope<RoomCreatePayload>>,
241}
242
243#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
244pub struct RoomJoinPayload {
245 pub request_id: String,
246 pub role: Role,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub perspective: Option<String>,
249}
250
251#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
252pub struct RoomJoinRequestPayload {
253 pub requested_role: Role,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub perspective: Option<String>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub reason: Option<String>,
258 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
259 pub extra: BTreeMap<String, Value>,
260}
261
262impl RoomJoinRequestPayload {
263 pub fn new(requested_role: Role) -> Self {
264 Self {
265 requested_role,
266 perspective: None,
267 reason: None,
268 extra: BTreeMap::new(),
269 }
270 }
271}
272
273#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
274pub struct RoomJoinRequest {
275 pub id: String,
276 pub room_id: String,
277 pub applicant: AgentId,
278 pub requested_role: Role,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub approved_role: Option<Role>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub perspective: Option<String>,
283 pub status: JoinRequestStatus,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub request_reason: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub review_reason: Option<String>,
288 pub created_at: i64,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub reviewed_by: Option<AgentId>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub reviewed_at: Option<i64>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub expires_at: Option<i64>,
295 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
296 pub extra: BTreeMap<String, Value>,
297}
298
299#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
300#[serde(rename_all = "snake_case")]
301pub enum JoinDecision {
302 Approve,
303 Reject,
304}
305
306#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
307pub struct RoomJoinReviewPayload {
308 pub request_id: String,
309 pub member: AgentId,
310 pub decision: JoinDecision,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub role: Option<Role>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub reason: Option<String>,
315}
316
317#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
318pub struct RoomLeavePayload {
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub reason: Option<String>,
321}
322
323#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
324pub struct RoleUpdatePayload {
325 pub member: AgentId,
326 pub role: Role,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub reason: Option<String>,
329}
330
331#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
332pub struct MessageCreatePayload {
333 pub content_type: String,
334 pub content: Value,
335 #[serde(default, skip_serializing_if = "Vec::is_empty")]
336 pub references: Vec<String>,
337}
338
339impl MessageCreatePayload {
340 pub fn new(content_type: impl Into<String>, content: Value) -> Self {
341 Self {
342 content_type: content_type.into(),
343 content,
344 references: Vec::new(),
345 }
346 }
347
348 pub fn text(text: impl Into<String>) -> Self {
349 Self::new("text/plain", Value::String(text.into()))
350 }
351
352 pub fn markdown(markdown: impl Into<String>) -> Self {
353 Self::new("text/markdown", Value::String(markdown.into()))
354 }
355}
356
357#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
358pub struct ProposalCreatePayload {
359 pub proposal_id: String,
360 pub title: String,
361 pub body: String,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub content_type: Option<String>,
364 #[serde(default, skip_serializing_if = "Vec::is_empty")]
365 pub references: Vec<String>,
366 #[serde(default, skip_serializing_if = "Vec::is_empty")]
367 pub source_event_ids: Vec<String>,
368 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
369 pub extra: BTreeMap<String, Value>,
370}
371
372#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
373pub struct PollOption {
374 pub id: String,
375 pub label: String,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub description: Option<String>,
378}
379
380#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
381pub struct PollCreatePayload {
382 pub poll_id: String,
383 pub question: String,
384 pub options: Vec<PollOption>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub min_choices: Option<u32>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub max_choices: Option<u32>,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub closes_at: Option<i64>,
391 #[serde(default, skip_serializing_if = "Vec::is_empty")]
392 pub references: Vec<String>,
393 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
394 pub extra: BTreeMap<String, Value>,
395}
396
397#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
398pub struct PollVotePayload {
399 pub event_id: String,
400 pub option_ids: Vec<String>,
401}
402
403#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
404pub struct ResolutionCreatePayload {
405 pub resolution_id: String,
406 pub outcome: ResolutionOutcome,
407 pub summary: String,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub proposal_event_id: Option<String>,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub poll_event_id: Option<String>,
412 #[serde(default, skip_serializing_if = "Vec::is_empty")]
413 pub references: Vec<String>,
414 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
415 pub extra: BTreeMap<String, Value>,
416}
417
418#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
419pub struct ReactionCreatePayload {
420 pub event_id: String,
421 pub reaction: String,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub score: Option<f64>,
424}
425
426impl ReactionCreatePayload {
427 pub fn new(event_id: impl Into<String>, reaction: impl Into<String>) -> Self {
428 Self {
429 event_id: event_id.into(),
430 reaction: reaction.into(),
431 score: None,
432 }
433 }
434}
435
436#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
437pub struct SourceAddPayload {
438 pub source_type: String,
439 pub uri: String,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub title: Option<String>,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub retrieved_at: Option<i64>,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub content_digest: Option<String>,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub excerpt: Option<String>,
448 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
449 pub extra: BTreeMap<String, Value>,
450}
451
452#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
453pub struct TurnUpdatePayload {
454 pub turn_id: u64,
455 pub speaker: AgentId,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub intent: Option<MessageIntent>,
458 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub topic: Option<String>,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub expires_at: Option<i64>,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub reason: Option<String>,
464}
465
466#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
467pub struct QuestionGeneratePayload {
468 pub question: String,
469 #[serde(default, skip_serializing_if = "Vec::is_empty")]
470 pub target_perspectives: Vec<String>,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub basis: Option<String>,
473 #[serde(default, skip_serializing_if = "Vec::is_empty")]
474 pub source_event_ids: Vec<String>,
475 #[serde(default, skip_serializing_if = "Vec::is_empty")]
476 pub references: Vec<String>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub priority: Option<f64>,
479}
480
481#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
482pub struct DiscourseSteerPayload {
483 pub instruction: String,
484 pub target: String,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub priority: Option<f64>,
487 #[serde(default, skip_serializing_if = "Vec::is_empty")]
488 pub references: Vec<String>,
489}
490
491#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
492#[serde(rename_all = "snake_case")]
493pub enum MapNodeStatus {
494 Open,
495 Resolved,
496 Closed,
497}
498
499#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
500pub struct MapNode {
501 pub id: String,
502 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub parent_id: Option<String>,
504 pub title: String,
505 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub summary: Option<String>,
507 #[serde(default, skip_serializing_if = "Option::is_none")]
508 pub status: Option<MapNodeStatus>,
509 #[serde(default, skip_serializing_if = "Vec::is_empty")]
510 pub source_event_ids: Vec<String>,
511 #[serde(default, skip_serializing_if = "Vec::is_empty")]
512 pub discussion_event_ids: Vec<String>,
513 #[serde(default, skip_serializing_if = "Vec::is_empty")]
514 pub children: Vec<MapNode>,
515 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
516 pub extra: BTreeMap<String, Value>,
517}
518
519#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
520pub struct MapUpdatePayload {
521 pub operation: MapOperation,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub node: Option<MapNode>,
524 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
525 pub extra: BTreeMap<String, Value>,
526 #[serde(default, flatten)]
527 pub extensions: BTreeMap<String, Value>,
528}
529
530#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
531pub struct ArtifactCreatePayload {
532 pub artifact_id: String,
533 pub format: String,
534 pub title: String,
535 pub uri: String,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
537 pub content_digest: Option<String>,
538 #[serde(default, skip_serializing_if = "Vec::is_empty")]
539 pub source_event_ids: Vec<String>,
540 #[serde(default, skip_serializing_if = "Vec::is_empty")]
541 pub discussion_event_ids: Vec<String>,
542 #[serde(default, skip_serializing_if = "Option::is_none")]
543 pub map_event_id: Option<String>,
544}
545
546#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
547pub struct SessionDescription {
548 #[serde(rename = "type")]
549 pub kind: SessionDescriptionType,
550 pub sdp: String,
551}
552
553#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
554pub struct SessionIceCandidate {
555 pub candidate: String,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub sdp_mid: Option<String>,
558 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub sdp_mline_index: Option<u32>,
560 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub username_fragment: Option<String>,
562}
563
564#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
565pub struct SessionDataTransfer {
566 pub transfer_id: String,
567 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub file_name: Option<String>,
569 #[serde(default, skip_serializing_if = "Option::is_none")]
570 pub size_bytes: Option<u64>,
571 #[serde(default, skip_serializing_if = "Option::is_none")]
572 pub mime_type: Option<String>,
573 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub content_digest: Option<String>,
575}
576
577#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
578pub struct SessionOfferPayload {
579 pub session_id: String,
580 pub session_type: SessionType,
581 pub media: Vec<SessionMediaKind>,
582 pub description: SessionDescription,
583 #[serde(default, skip_serializing_if = "Vec::is_empty")]
584 pub to: Vec<AgentId>,
585 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub topology: Option<SessionTopology>,
587 #[serde(default, skip_serializing_if = "Vec::is_empty")]
588 pub transfers: Vec<SessionDataTransfer>,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub expires_at: Option<i64>,
591 #[serde(default, skip_serializing_if = "Vec::is_empty")]
592 pub references: Vec<String>,
593 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
594 pub extra: BTreeMap<String, Value>,
595}
596
597#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
598pub struct SessionAnswerPayload {
599 pub session_id: String,
600 pub offer_event_id: String,
601 pub description: SessionDescription,
602 #[serde(default, skip_serializing_if = "Vec::is_empty")]
603 pub accepted_media: Vec<SessionMediaKind>,
604 #[serde(default, skip_serializing_if = "Vec::is_empty")]
605 pub transfers: Vec<SessionDataTransfer>,
606 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
607 pub extra: BTreeMap<String, Value>,
608}
609
610#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
611pub struct SessionCandidatePayload {
612 pub session_id: String,
613 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub candidate: Option<SessionIceCandidate>,
615 #[serde(default, skip_serializing_if = "Option::is_none")]
616 pub target: Option<AgentId>,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
618 pub end_of_candidates: Option<bool>,
619 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
620 pub extra: BTreeMap<String, Value>,
621}
622
623#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
624pub struct SessionClosePayload {
625 pub session_id: String,
626 #[serde(default, skip_serializing_if = "Option::is_none")]
627 pub reason: Option<String>,
628 #[serde(default, skip_serializing_if = "Vec::is_empty")]
629 pub references: Vec<String>,
630}
631
632#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
633pub struct RoomClosePayload {
634 #[serde(default, skip_serializing_if = "Option::is_none")]
635 pub reason: Option<String>,
636}
637
638#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
639pub struct RoomCancelPayload {
640 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub reason: Option<String>,
642}
643
644#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
645pub struct ProfileResolverMetadata {
646 pub mode: String,
647 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub service: Option<String>,
649 #[serde(default, skip_serializing_if = "Option::is_none")]
650 pub protocol: Option<String>,
651}
652
653#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
654pub struct DiscourseProtocolDiscovery {
655 pub protocol: String,
656 pub host: String,
657 #[serde(default, skip_serializing_if = "Vec::is_empty")]
658 pub features: Vec<String>,
659 #[serde(default, skip_serializing_if = "Option::is_none")]
660 pub profile: Option<ProfileResolverMetadata>,
661 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
662 pub endpoints: BTreeMap<String, String>,
663}
664
665#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
666pub struct MapSnapshotRef {
667 pub event_id: String,
668 pub digest: String,
669}
670
671#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
672pub struct ArtifactManifest {
673 pub artifact_id: String,
674 #[serde(rename = "type")]
675 pub kind: String,
676 pub format: String,
677 pub uri: String,
678 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub content_digest: Option<String>,
680}
681
682#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
683pub struct ArchiveManifest {
684 pub protocol: String,
685 #[serde(rename = "type")]
686 pub kind: String,
687 pub host: String,
688 pub room_id: String,
689 pub url: String,
690 pub generated_at: i64,
691 pub event_count: u64,
692 pub first_seq: u64,
693 pub last_seq: u64,
694 pub last_hash: String,
695 pub events_sha3_256: String,
696 pub archive_root: String,
697 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub map_snapshot: Option<MapSnapshotRef>,
699 #[serde(default, skip_serializing_if = "Vec::is_empty")]
700 pub artifacts: Vec<ArtifactManifest>,
701 #[serde(default, skip_serializing_if = "Option::is_none")]
702 pub discourse_trace_quality_score: Option<f64>,
703 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
704 pub formats: BTreeMap<String, String>,
705}
706
707pub fn room_create_event(
708 actor: AgentId,
709 created_at: i64,
710 nonce: u64,
711 payload: RoomCreatePayload,
712) -> Event<RoomCreatePayload> {
713 Event::new(
714 PROTOCOL,
715 event_type::ROOM_CREATE,
716 actor,
717 created_at,
718 nonce,
719 payload,
720 )
721}
722
723pub fn discourse_event<P>(
724 kind: impl Into<String>,
725 actor: AgentId,
726 created_at: i64,
727 nonce: u64,
728 room_id: impl Into<String>,
729 payload: P,
730) -> Event<P> {
731 Event::new(PROTOCOL, kind, actor, created_at, nonce, payload).with_room_id(room_id)
732}
733
734pub fn validate_discourse_envelope<P>(envelope: &Envelope<P>) -> Result<()>
735where
736 P: Serialize,
737{
738 verify_envelope(envelope)?;
739 let protocol = envelope.event.protocol.as_str();
740 if protocol != PROTOCOL {
741 return Err(SdkError::InvalidEventProtocol {
742 expected: PROTOCOL.to_owned(),
743 actual: envelope.event.protocol.clone(),
744 });
745 }
746 if event_requires_room_id(&envelope.event.kind) && envelope.event.room_id.is_none() {
747 return Err(SdkError::MissingRoomId);
748 }
749 Ok(())
750}
751
752pub fn validate_room_path<P>(envelope: &Envelope<P>, path_room_id: &str) -> Result<()> {
753 match envelope.event.room_id.as_deref() {
754 Some(actual) if actual == path_room_id => Ok(()),
755 Some(actual) => Err(SdkError::RoomIdMismatch {
756 expected: path_room_id.to_owned(),
757 actual: actual.to_owned(),
758 }),
759 None if envelope.event.kind == event_type::ROOM_CREATE => Ok(()),
760 None => Err(SdkError::MissingRoomId),
761 }
762}
763
764pub fn event_requires_room_id(event_type: &str) -> bool {
765 event_type != event_type::ROOM_CREATE
766}
767
768pub fn validate_room_create_payload(payload: &RoomCreatePayload) -> Result<()> {
769 if payload.topic.trim().is_empty() {
770 return Err(SdkError::InvalidPayload(
771 "room topic must not be empty".to_owned(),
772 ));
773 }
774 if payload.start_time >= payload.end_time {
775 return Err(SdkError::InvalidPayload(
776 "start_time must be before end_time".to_owned(),
777 ));
778 }
779 if let Some(policy) = &payload.policy {
780 if matches!(policy.max_participants, Some(0)) {
781 return Err(SdkError::InvalidPayload(
782 "max_participants must be a positive integer".to_owned(),
783 ));
784 }
785 }
786 Ok(())
787}
788
789pub fn validate_poll_create_payload(payload: &PollCreatePayload) -> Result<()> {
790 if payload.poll_id.trim().is_empty() || payload.question.trim().is_empty() {
791 return Err(SdkError::InvalidPayload(
792 "poll_id and question are required".to_owned(),
793 ));
794 }
795 if payload.options.len() < 2 {
796 return Err(SdkError::InvalidPayload(
797 "poll requires at least two options".to_owned(),
798 ));
799 }
800 let mut option_ids = BTreeSet::new();
801 for option in &payload.options {
802 if option.id.trim().is_empty() || option.label.trim().is_empty() {
803 return Err(SdkError::InvalidPayload(
804 "option id and label are required".to_owned(),
805 ));
806 }
807 if !option_ids.insert(option.id.as_str()) {
808 return Err(SdkError::InvalidPayload(
809 "poll option ids must be unique".to_owned(),
810 ));
811 }
812 }
813 let min_choices = payload.min_choices.unwrap_or(1);
814 let max_choices = payload.max_choices.unwrap_or(1);
815 if min_choices < 1 || max_choices < min_choices {
816 return Err(SdkError::InvalidPayload(
817 "invalid poll choice limits".to_owned(),
818 ));
819 }
820 Ok(())
821}
822
823pub fn validate_poll_vote_payload(
824 payload: &PollVotePayload,
825 poll: &PollCreatePayload,
826 now_ms: Option<i64>,
827) -> Result<()> {
828 if let (Some(closes_at), Some(now_ms)) = (poll.closes_at, now_ms) {
829 if now_ms > closes_at {
830 return Err(SdkError::InvalidPayload("poll is closed".to_owned()));
831 }
832 }
833 let min_choices = poll.min_choices.unwrap_or(1) as usize;
834 let max_choices = poll.max_choices.unwrap_or(1) as usize;
835 let option_ids: BTreeSet<&str> = poll
836 .options
837 .iter()
838 .map(|option| option.id.as_str())
839 .collect();
840 let selected: BTreeSet<&str> = payload.option_ids.iter().map(String::as_str).collect();
841 if selected.len() != payload.option_ids.len() {
842 return Err(SdkError::InvalidPayload(
843 "duplicate poll options".to_owned(),
844 ));
845 }
846 if selected.len() < min_choices || selected.len() > max_choices {
847 return Err(SdkError::InvalidPayload(
848 "invalid number of options".to_owned(),
849 ));
850 }
851 if selected
852 .iter()
853 .any(|option_id| !option_ids.contains(option_id))
854 {
855 return Err(SdkError::InvalidPayload("unknown poll option".to_owned()));
856 }
857 Ok(())
858}
859
860pub fn validate_session_offer_payload(payload: &SessionOfferPayload) -> Result<()> {
861 validate_session_id(&payload.session_id)?;
862 if payload.media.is_empty() {
863 return Err(SdkError::InvalidPayload(
864 "media must not be empty".to_owned(),
865 ));
866 }
867 validate_session_description(&payload.description, SessionDescriptionType::Offer)?;
868 validate_session_transfers(&payload.transfers)
869}
870
871pub fn validate_session_answer_payload(payload: &SessionAnswerPayload) -> Result<()> {
872 validate_session_id(&payload.session_id)?;
873 if payload.offer_event_id.trim().is_empty() {
874 return Err(SdkError::InvalidPayload(
875 "offer_event_id is required".to_owned(),
876 ));
877 }
878 validate_session_description(&payload.description, SessionDescriptionType::Answer)?;
879 validate_session_transfers(&payload.transfers)
880}
881
882pub fn validate_session_candidate_payload(payload: &SessionCandidatePayload) -> Result<()> {
883 validate_session_id(&payload.session_id)?;
884 if payload.end_of_candidates.unwrap_or(false) {
885 return Ok(());
886 }
887 match &payload.candidate {
888 Some(candidate) if !candidate.candidate.trim().is_empty() => Ok(()),
889 _ => Err(SdkError::InvalidPayload(
890 "candidate is required unless end_of_candidates is true".to_owned(),
891 )),
892 }
893}
894
895pub fn server_record_hash_payload(
896 room_id: &str,
897 seq: u64,
898 pre_hash: Option<&str>,
899 envelope_hash: &str,
900 received_at: i64,
901) -> ServerRecordHashPayload {
902 ServerRecordHashPayload {
903 room_id: room_id.to_owned(),
904 seq,
905 pre_hash: pre_hash.map(str::to_owned),
906 envelope_hash: envelope_hash.to_owned(),
907 received_at,
908 }
909}
910
911pub fn server_record_hash(
912 room_id: &str,
913 seq: u64,
914 pre_hash: Option<&str>,
915 envelope_hash: &str,
916 received_at: i64,
917) -> Result<String> {
918 hash_canonical_json(&server_record_hash_payload(
919 room_id,
920 seq,
921 pre_hash,
922 envelope_hash,
923 received_at,
924 ))
925}
926
927pub fn build_server_record<P>(
928 room_id: impl Into<String>,
929 seq: u64,
930 pre_hash: Option<String>,
931 received_at: i64,
932 envelope: Envelope<P>,
933) -> Result<ServerRecord<P>> {
934 let room_id = room_id.into();
935 let hash = server_record_hash(
936 &room_id,
937 seq,
938 pre_hash.as_deref(),
939 &envelope.hash,
940 received_at,
941 )?;
942 Ok(ServerRecord {
943 room_id,
944 seq,
945 pre_hash,
946 hash,
947 received_at,
948 envelope,
949 })
950}
951
952pub fn verify_server_record<P>(record: &ServerRecord<P>) -> Result<()>
953where
954 P: Serialize,
955{
956 let expected = server_record_hash(
957 &record.room_id,
958 record.seq,
959 record.pre_hash.as_deref(),
960 &record.envelope.hash,
961 record.received_at,
962 )?;
963 if record.hash == expected {
964 Ok(())
965 } else {
966 Err(SdkError::InvalidEventHash {
967 expected,
968 actual: record.hash.clone(),
969 })
970 }
971}
972
973pub fn verify_server_record_chain<P>(records: &[ServerRecord<P>]) -> Result<()>
974where
975 P: Serialize,
976{
977 let mut previous: Option<&ServerRecord<P>> = None;
978 for record in records {
979 verify_server_record(record)?;
980 if let Some(previous) = previous {
981 if record.seq != previous.seq + 1 {
982 return Err(SdkError::InvalidPayload(
983 "seq must increase by 1".to_owned(),
984 ));
985 }
986 if record.pre_hash.as_deref() != Some(previous.hash.as_str()) {
987 return Err(SdkError::InvalidPayload("pre_hash mismatch".to_owned()));
988 }
989 } else if record.seq != 1 {
990 return Err(SdkError::InvalidPayload("first seq must be 1".to_owned()));
991 } else if record.pre_hash.is_some() {
992 return Err(SdkError::InvalidPayload(
993 "first pre_hash must be null".to_owned(),
994 ));
995 }
996 previous = Some(record);
997 }
998 Ok(())
999}
1000
1001pub fn archive_events_digest<P>(records: &[ServerRecord<P>]) -> Result<String>
1002where
1003 P: Serialize,
1004{
1005 hash_canonical_json(records)
1006}
1007
1008#[derive(Clone, Debug, Default, PartialEq, Eq)]
1009pub struct PermissionContext {
1010 pub role: Option<Role>,
1011 pub is_creator: bool,
1012 pub join_request_approved: bool,
1013 pub moderator_authorized: bool,
1014 pub expert_policy_allowed: bool,
1015 pub participant_policy_allowed: bool,
1016 pub observer_steering_allowed: bool,
1017 pub observer_poll_vote_allowed: bool,
1018}
1019
1020impl PermissionContext {
1021 pub fn for_role(role: Role) -> Self {
1022 Self {
1023 role: Some(role),
1024 ..Self::default()
1025 }
1026 }
1027
1028 pub fn creator(role: Option<Role>) -> Self {
1029 Self {
1030 role,
1031 is_creator: true,
1032 ..Self::default()
1033 }
1034 }
1035}
1036
1037#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1038pub struct StateWriteOptions {
1039 pub post_end_reaction_allowed: bool,
1040}
1041
1042pub fn can_submit_event(event_type: &str, context: &PermissionContext) -> bool {
1043 if event_type == event_type::ROOM_CREATE {
1044 return true;
1045 }
1046 if event_type == event_type::ROOM_JOIN {
1047 return context.join_request_approved;
1048 }
1049
1050 if context.is_creator {
1051 return is_known_event_type(event_type);
1052 }
1053
1054 match context.role {
1055 Some(Role::Moderator) => moderator_can_submit(event_type, context.moderator_authorized),
1056 Some(Role::Expert) => speaker_can_submit(event_type, context.expert_policy_allowed),
1057 Some(Role::Participant) => {
1058 speaker_can_submit(event_type, context.participant_policy_allowed)
1059 }
1060 Some(Role::Observer) => observer_can_submit(event_type, context),
1061 None => false,
1062 }
1063}
1064
1065pub fn can_write_in_state(event_type: &str, state: RoomState, options: StateWriteOptions) -> bool {
1066 match state {
1067 RoomState::Scheduled => matches!(
1068 event_type,
1069 event_type::ROOM_JOIN | event_type::ROOM_JOIN_REVIEW | event_type::ROOM_CANCEL
1070 ),
1071 RoomState::Active => {
1072 event_type != event_type::ROOM_CREATE && event_type != event_type::ROOM_CANCEL
1073 }
1074 RoomState::Ended => {
1075 options.post_end_reaction_allowed && event_type == event_type::REACTION_CREATE
1076 }
1077 RoomState::Cancelled => false,
1078 }
1079}
1080
1081pub fn can_accept_room_write(
1082 event_type: &str,
1083 state: RoomState,
1084 permission: &PermissionContext,
1085 state_options: StateWriteOptions,
1086) -> bool {
1087 can_submit_event(event_type, permission) && can_write_in_state(event_type, state, state_options)
1088}
1089
1090pub fn validate_room_write(
1091 event_type: &str,
1092 state: RoomState,
1093 permission: &PermissionContext,
1094 state_options: StateWriteOptions,
1095) -> Result<()> {
1096 if can_accept_room_write(event_type, state, permission, state_options) {
1097 Ok(())
1098 } else {
1099 Err(SdkError::PermissionDenied)
1100 }
1101}
1102
1103fn moderator_can_submit(event_type: &str, moderator_authorized: bool) -> bool {
1104 matches!(
1105 event_type,
1106 event_type::ROOM_JOIN_REVIEW
1107 | event_type::ROOM_CLOSE
1108 | event_type::MESSAGE_CREATE
1109 | event_type::SOURCE_ADD
1110 | event_type::TURN_UPDATE
1111 | event_type::QUESTION_CREATE
1112 | event_type::ROOM_STEER
1113 | event_type::MAP_UPDATE
1114 | event_type::ARTIFACT_CREATE
1115 | event_type::SESSION_OFFER
1116 | event_type::SESSION_ANSWER
1117 | event_type::SESSION_CANDIDATE
1118 | event_type::SESSION_CLOSE
1119 | event_type::MESSAGE_PROPOSAL_CREATE
1120 | event_type::MESSAGE_POLL_CREATE
1121 | event_type::MESSAGE_POLL_VOTE
1122 | event_type::MESSAGE_RESOLUTION_CREATE
1123 | event_type::REACTION_CREATE
1124 | event_type::ROOM_LEAVE
1125 ) || (moderator_authorized
1126 && matches!(
1127 event_type,
1128 event_type::ROOM_MEMBER_ROLE_UPDATE | event_type::ROOM_CANCEL
1129 ))
1130}
1131
1132fn speaker_can_submit(event_type: &str, policy_allowed: bool) -> bool {
1133 matches!(
1134 event_type,
1135 event_type::MESSAGE_CREATE
1136 | event_type::SOURCE_ADD
1137 | event_type::ROOM_STEER
1138 | event_type::MESSAGE_PROPOSAL_CREATE
1139 | event_type::MESSAGE_POLL_CREATE
1140 | event_type::MESSAGE_POLL_VOTE
1141 | event_type::SESSION_OFFER
1142 | event_type::SESSION_ANSWER
1143 | event_type::SESSION_CANDIDATE
1144 | event_type::SESSION_CLOSE
1145 | event_type::REACTION_CREATE
1146 | event_type::ROOM_LEAVE
1147 ) || (policy_allowed
1148 && matches!(
1149 event_type,
1150 event_type::QUESTION_CREATE
1151 | event_type::MAP_UPDATE
1152 | event_type::ARTIFACT_CREATE
1153 | event_type::MESSAGE_RESOLUTION_CREATE
1154 ))
1155}
1156
1157fn observer_can_submit(event_type: &str, context: &PermissionContext) -> bool {
1158 matches!(
1159 event_type,
1160 event_type::REACTION_CREATE | event_type::ROOM_LEAVE
1161 ) || (context.observer_steering_allowed && event_type == event_type::ROOM_STEER)
1162 || (context.observer_poll_vote_allowed && event_type == event_type::MESSAGE_POLL_VOTE)
1163}
1164
1165fn is_known_event_type(event_type: &str) -> bool {
1166 matches!(
1167 event_type,
1168 event_type::ROOM_CREATE
1169 | event_type::ROOM_JOIN
1170 | event_type::ROOM_JOIN_REVIEW
1171 | event_type::ROOM_LEAVE
1172 | event_type::ROOM_MEMBER_ROLE_UPDATE
1173 | event_type::ROOM_CLOSE
1174 | event_type::ROOM_CANCEL
1175 | event_type::MESSAGE_CREATE
1176 | event_type::REACTION_CREATE
1177 | event_type::MESSAGE_PROPOSAL_CREATE
1178 | event_type::MESSAGE_POLL_CREATE
1179 | event_type::MESSAGE_POLL_VOTE
1180 | event_type::MESSAGE_RESOLUTION_CREATE
1181 | event_type::SOURCE_ADD
1182 | event_type::TURN_UPDATE
1183 | event_type::QUESTION_CREATE
1184 | event_type::ROOM_STEER
1185 | event_type::MAP_UPDATE
1186 | event_type::ARTIFACT_CREATE
1187 | event_type::SESSION_OFFER
1188 | event_type::SESSION_ANSWER
1189 | event_type::SESSION_CANDIDATE
1190 | event_type::SESSION_CLOSE
1191 )
1192}
1193
1194fn validate_session_id(session_id: &str) -> Result<()> {
1195 if session_id.trim().is_empty() {
1196 Err(SdkError::InvalidPayload(
1197 "session_id is required".to_owned(),
1198 ))
1199 } else {
1200 Ok(())
1201 }
1202}
1203
1204fn validate_session_description(
1205 description: &SessionDescription,
1206 expected_type: SessionDescriptionType,
1207) -> Result<()> {
1208 if description.kind != expected_type {
1209 return Err(SdkError::InvalidPayload(format!(
1210 "session description type must be {}",
1211 match expected_type {
1212 SessionDescriptionType::Offer => "offer",
1213 SessionDescriptionType::Answer => "answer",
1214 }
1215 )));
1216 }
1217 if description.sdp.trim().is_empty() {
1218 return Err(SdkError::InvalidPayload(
1219 "session description sdp is required".to_owned(),
1220 ));
1221 }
1222 Ok(())
1223}
1224
1225fn validate_session_transfers(transfers: &[SessionDataTransfer]) -> Result<()> {
1226 for transfer in transfers {
1227 if transfer.transfer_id.trim().is_empty() {
1228 return Err(SdkError::InvalidPayload(
1229 "transfer_id is required".to_owned(),
1230 ));
1231 }
1232 }
1233 Ok(())
1234}
1235
1236fn hash_canonical_json<T>(value: &T) -> Result<String>
1237where
1238 T: Serialize + ?Sized,
1239{
1240 let bytes = serde_jcs::to_vec(value).map_err(|err| SdkError::CanonicalJson(err.to_string()))?;
1241 let digest = Sha3_256::digest(bytes);
1242 Ok(URL_SAFE_NO_PAD.encode(digest))
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247 use super::*;
1248 use crate::identity::AgentSigner;
1249 use serde_json::json;
1250
1251 #[test]
1252 fn validates_room_create_without_room_id() {
1253 let signer = AgentSigner::from_seed([14; 32]);
1254 let payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1255 let event = Event::new(
1256 PROTOCOL,
1257 event_type::ROOM_CREATE,
1258 signer.agent_id(),
1259 100,
1260 1,
1261 payload,
1262 );
1263 let envelope = signer.sign_event(event).unwrap();
1264
1265 validate_discourse_envelope(&envelope).unwrap();
1266 validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").unwrap();
1267 }
1268
1269 #[test]
1270 fn rejects_room_event_without_room_id() {
1271 let signer = AgentSigner::from_seed([15; 32]);
1272 let event = Event::new(
1273 PROTOCOL,
1274 event_type::MESSAGE_CREATE,
1275 signer.agent_id(),
1276 100,
1277 1,
1278 MessageCreatePayload::text("hello"),
1279 );
1280 let envelope = signer.sign_event(event).unwrap();
1281
1282 assert!(matches!(
1283 validate_discourse_envelope(&envelope),
1284 Err(SdkError::MissingRoomId)
1285 ));
1286 }
1287
1288 #[test]
1289 fn applies_permission_matrix() {
1290 let observer = PermissionContext::for_role(Role::Observer);
1291 assert!(can_submit_event(event_type::REACTION_CREATE, &observer));
1292 assert!(!can_submit_event(event_type::MESSAGE_CREATE, &observer));
1293 assert!(!can_submit_event(event_type::ROOM_JOIN, &observer));
1294
1295 let approved_join = PermissionContext {
1296 join_request_approved: true,
1297 ..PermissionContext::default()
1298 };
1299 assert!(can_submit_event(event_type::ROOM_JOIN, &approved_join));
1300
1301 let mut moderator = PermissionContext::for_role(Role::Moderator);
1302 assert!(can_submit_event(event_type::ROOM_JOIN_REVIEW, &moderator));
1303 assert!(!can_submit_event(event_type::ROOM_CANCEL, &moderator));
1304 moderator.moderator_authorized = true;
1305 assert!(can_submit_event(event_type::ROOM_CANCEL, &moderator));
1306
1307 let participant = PermissionContext::for_role(Role::Participant);
1308 assert!(can_submit_event(event_type::SESSION_OFFER, &participant));
1309 assert!(!can_submit_event(
1310 event_type::ROOM_JOIN_REVIEW,
1311 &participant
1312 ));
1313 assert!(!can_submit_event(event_type::SESSION_CANDIDATE, &observer));
1314 }
1315
1316 #[test]
1317 fn applies_state_restrictions() {
1318 let participant = PermissionContext::for_role(Role::Participant);
1319 assert!(can_accept_room_write(
1320 event_type::MESSAGE_CREATE,
1321 RoomState::Active,
1322 &participant,
1323 StateWriteOptions::default()
1324 ));
1325 assert!(!can_accept_room_write(
1326 event_type::MESSAGE_CREATE,
1327 RoomState::Scheduled,
1328 &participant,
1329 StateWriteOptions::default()
1330 ));
1331 assert!(!can_accept_room_write(
1332 event_type::REACTION_CREATE,
1333 RoomState::Ended,
1334 &participant,
1335 StateWriteOptions::default()
1336 ));
1337 assert!(can_accept_room_write(
1338 event_type::ROOM_JOIN_REVIEW,
1339 RoomState::Scheduled,
1340 &PermissionContext::for_role(Role::Moderator),
1341 StateWriteOptions::default()
1342 ));
1343 assert!(can_accept_room_write(
1344 event_type::ROOM_JOIN,
1345 RoomState::Scheduled,
1346 &PermissionContext {
1347 join_request_approved: true,
1348 ..PermissionContext::default()
1349 },
1350 StateWriteOptions::default()
1351 ));
1352 assert!(can_accept_room_write(
1353 event_type::REACTION_CREATE,
1354 RoomState::Ended,
1355 &participant,
1356 StateWriteOptions {
1357 post_end_reaction_allowed: true,
1358 }
1359 ));
1360 }
1361
1362 #[test]
1363 fn validates_room_creation_payloads() {
1364 let mut payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1365 payload.policy = Some(RoomPolicy {
1366 max_participants: Some(2),
1367 ..RoomPolicy::default()
1368 });
1369 validate_room_create_payload(&payload).unwrap();
1370
1371 let empty_topic = RoomCreatePayload::new(" ", Visibility::Public, 1000, 2000);
1372 assert!(matches!(
1373 validate_room_create_payload(&empty_topic),
1374 Err(SdkError::InvalidPayload(_))
1375 ));
1376
1377 let invalid_time = RoomCreatePayload::new("Research room", Visibility::Public, 2000, 1000);
1378 assert!(matches!(
1379 validate_room_create_payload(&invalid_time),
1380 Err(SdkError::InvalidPayload(_))
1381 ));
1382 }
1383
1384 #[test]
1385 fn validates_poll_payloads_and_votes() {
1386 let poll = PollCreatePayload {
1387 poll_id: "poll_review_order".to_owned(),
1388 question: "Which review order?".to_owned(),
1389 options: vec![
1390 PollOption {
1391 id: "a".to_owned(),
1392 label: "Correctness first".to_owned(),
1393 description: None,
1394 },
1395 PollOption {
1396 id: "b".to_owned(),
1397 label: "Security first".to_owned(),
1398 description: None,
1399 },
1400 ],
1401 min_choices: Some(1),
1402 max_choices: Some(1),
1403 closes_at: None,
1404 references: Vec::new(),
1405 extra: BTreeMap::new(),
1406 };
1407
1408 validate_poll_create_payload(&poll).unwrap();
1409 validate_poll_vote_payload(
1410 &PollVotePayload {
1411 event_id: "evt".to_owned(),
1412 option_ids: vec!["a".to_owned()],
1413 },
1414 &poll,
1415 None,
1416 )
1417 .unwrap();
1418 assert!(matches!(
1419 validate_poll_vote_payload(
1420 &PollVotePayload {
1421 event_id: "evt".to_owned(),
1422 option_ids: vec!["a".to_owned(), "b".to_owned()],
1423 },
1424 &poll,
1425 None,
1426 ),
1427 Err(SdkError::InvalidPayload(_))
1428 ));
1429
1430 let mut duplicate = poll.clone();
1431 duplicate.options[1].id = "a".to_owned();
1432 assert!(matches!(
1433 validate_poll_create_payload(&duplicate),
1434 Err(SdkError::InvalidPayload(_))
1435 ));
1436 }
1437
1438 #[test]
1439 fn validates_webrtc_session_payloads() {
1440 let offer = SessionOfferPayload {
1441 session_id: "sess_live_review".to_owned(),
1442 session_type: SessionType::Webrtc,
1443 media: vec![
1444 SessionMediaKind::Audio,
1445 SessionMediaKind::Video,
1446 SessionMediaKind::File,
1447 ],
1448 description: SessionDescription {
1449 kind: SessionDescriptionType::Offer,
1450 sdp: "v=0\r\n...".to_owned(),
1451 },
1452 to: Vec::new(),
1453 topology: Some(SessionTopology::PeerToPeer),
1454 transfers: vec![SessionDataTransfer {
1455 transfer_id: "file_1".to_owned(),
1456 file_name: Some("trace.har".to_owned()),
1457 size_bytes: Some(1024),
1458 mime_type: Some("application/json".to_owned()),
1459 content_digest: Some("sha256:abc".to_owned()),
1460 }],
1461 expires_at: None,
1462 references: Vec::new(),
1463 extra: BTreeMap::new(),
1464 };
1465 validate_session_offer_payload(&offer).unwrap();
1466
1467 validate_session_answer_payload(&SessionAnswerPayload {
1468 session_id: "sess_live_review".to_owned(),
1469 offer_event_id: "evt_offer".to_owned(),
1470 description: SessionDescription {
1471 kind: SessionDescriptionType::Answer,
1472 sdp: "v=0\r\n...".to_owned(),
1473 },
1474 accepted_media: vec![SessionMediaKind::Audio, SessionMediaKind::File],
1475 transfers: Vec::new(),
1476 extra: BTreeMap::new(),
1477 })
1478 .unwrap();
1479
1480 validate_session_candidate_payload(&SessionCandidatePayload {
1481 session_id: "sess_live_review".to_owned(),
1482 candidate: Some(SessionIceCandidate {
1483 candidate: "candidate:1 1 udp 1 127.0.0.1 3478 typ host".to_owned(),
1484 sdp_mid: None,
1485 sdp_mline_index: None,
1486 username_fragment: None,
1487 }),
1488 target: None,
1489 end_of_candidates: None,
1490 extra: BTreeMap::new(),
1491 })
1492 .unwrap();
1493
1494 validate_session_candidate_payload(&SessionCandidatePayload {
1495 session_id: "sess_live_review".to_owned(),
1496 candidate: None,
1497 target: None,
1498 end_of_candidates: Some(true),
1499 extra: BTreeMap::new(),
1500 })
1501 .unwrap();
1502
1503 let mut invalid_offer = offer;
1504 invalid_offer.description.kind = SessionDescriptionType::Answer;
1505 assert!(matches!(
1506 validate_session_offer_payload(&invalid_offer),
1507 Err(SdkError::InvalidPayload(_))
1508 ));
1509
1510 assert!(matches!(
1511 validate_session_candidate_payload(&SessionCandidatePayload {
1512 session_id: "sess_live_review".to_owned(),
1513 candidate: None,
1514 target: None,
1515 end_of_candidates: None,
1516 extra: BTreeMap::new(),
1517 }),
1518 Err(SdkError::InvalidPayload(_))
1519 ));
1520 }
1521
1522 #[test]
1523 fn builds_and_verifies_server_record_chains() {
1524 let signer = AgentSigner::from_seed([18; 32]);
1525 let envelope1 = signer
1526 .sign_event(Event::new(
1527 PROTOCOL,
1528 event_type::ROOM_CREATE,
1529 signer.agent_id(),
1530 100,
1531 1,
1532 json!({
1533 "topic": "Research room",
1534 "visibility": "public",
1535 "start_time": 1000,
1536 "end_time": 2000
1537 }),
1538 ))
1539 .unwrap();
1540 let record1 = build_server_record("room123", 1, None, 110, envelope1).unwrap();
1541
1542 let envelope2 = signer
1543 .sign_event(discourse_event(
1544 event_type::MESSAGE_CREATE,
1545 signer.agent_id(),
1546 120,
1547 2,
1548 "room123",
1549 json!({"content_type": "text/plain", "content": "hello"}),
1550 ))
1551 .unwrap();
1552 let record2 =
1553 build_server_record("room123", 2, Some(record1.hash.clone()), 130, envelope2).unwrap();
1554
1555 assert_eq!(
1556 record1.hash,
1557 server_record_hash("room123", 1, None, &record1.envelope.hash, 110).unwrap()
1558 );
1559 verify_server_record(&record1).unwrap();
1560 verify_server_record_chain(&[record1.clone(), record2.clone()]).unwrap();
1561 assert_eq!(
1562 archive_events_digest(&[record1.clone(), record2.clone()])
1563 .unwrap()
1564 .len(),
1565 43
1566 );
1567 assert!(verify_server_record_chain(&[record2]).is_err());
1568
1569 let broken = ServerRecord {
1570 pre_hash: Some("bad".to_owned()),
1571 ..record1
1572 };
1573 assert!(verify_server_record_chain(&[broken]).is_err());
1574 }
1575}