1use std::collections::{BTreeMap, BTreeSet};
10
11use base64::engine::general_purpose::URL_SAFE_NO_PAD;
12use base64::Engine as _;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use sha2::Sha256;
16use sha3::{Digest, Sha3_256};
17
18use crate::error::{Result, SdkError};
19use crate::identity::{verify_envelope, AgentId, Envelope, Event};
20
21pub const PROTOCOL: &str = "agent-discourse/1.0";
22
23pub mod event_type {
25 pub const ROOM_CREATE: &str = "room.create";
26 pub const ROOM_JOIN: &str = "room.join";
27 pub const ROOM_JOIN_REVIEW: &str = "room.join.review";
28 pub const ROOM_LEAVE: &str = "room.leave";
29 pub const ROOM_MEMBER_ROLE_UPDATE: &str = "room.member.role.update";
30 pub const ROOM_CLOSE: &str = "room.close";
31 pub const ROOM_CANCEL: &str = "room.cancel";
32 pub const TYPE_DEFINE: &str = "type.define";
33 pub const MESSAGE_CREATE: &str = "message.create";
34}
35
36pub const BUILTIN_EVENT_TYPES: [&str; 9] = [
37 event_type::ROOM_CREATE,
38 event_type::ROOM_JOIN,
39 event_type::ROOM_JOIN_REVIEW,
40 event_type::ROOM_LEAVE,
41 event_type::ROOM_MEMBER_ROLE_UPDATE,
42 event_type::ROOM_CLOSE,
43 event_type::ROOM_CANCEL,
44 event_type::TYPE_DEFINE,
45 event_type::MESSAGE_CREATE,
46];
47
48pub const RESERVED_TYPE_PREFIXES: [&str; 2] = ["room.", "type."];
50
51pub mod pack_id {
53 pub const REACTIONS: &str = "adp:reactions/1.0";
54 pub const DELIBERATION: &str = "adp:deliberation/1.0";
55 pub const CURATION: &str = "adp:curation/1.0";
56 pub const MODERATION: &str = "adp:moderation/1.0";
57 pub const REALTIME: &str = "adp:realtime/1.0";
58
59 pub const REGISTERED: [&str; 5] = [REACTIONS, DELIBERATION, CURATION, MODERATION, REALTIME];
60}
61
62#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
63#[serde(rename_all = "snake_case")]
64pub enum RoomState {
65 Scheduled,
66 Active,
67 Ended,
68 Cancelled,
69}
70
71#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(rename_all = "snake_case")]
73pub enum Visibility {
74 Public,
75 Restricted,
76 Private,
77}
78
79#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
80#[serde(rename_all = "snake_case")]
81pub enum Role {
82 Moderator,
83 Speaker,
84 Observer,
85}
86
87#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "snake_case")]
90pub enum TypeKind {
91 Message,
92 Signal,
93 Control,
94}
95
96#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
97#[serde(rename_all = "snake_case")]
98pub enum TypeStatus {
99 Active,
100 Deprecated,
101 Disabled,
102}
103
104#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "snake_case")]
106pub enum JoinRequestStatus {
107 Pending,
108 Approved,
109 Rejected,
110 Expired,
111}
112
113#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
114#[serde(rename_all = "snake_case")]
115pub enum JoinDecision {
116 Approve,
117 Reject,
118}
119
120#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
121pub struct RoomCreatePayload {
122 pub topic: String,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub agenda: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub guidance: Option<String>,
127 pub visibility: Visibility,
128 pub start_time: i64,
129 pub end_time: i64,
130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub tags: Vec<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub language: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub policy: Option<RoomPolicy>,
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 pub types: Vec<TypeDeclaration>,
138 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
139 pub extra: BTreeMap<String, Value>,
140}
141
142impl RoomCreatePayload {
143 pub fn new(
144 topic: impl Into<String>,
145 visibility: Visibility,
146 start_time: i64,
147 end_time: i64,
148 ) -> Self {
149 Self {
150 topic: topic.into(),
151 agenda: None,
152 guidance: None,
153 visibility,
154 start_time,
155 end_time,
156 tags: Vec::new(),
157 language: None,
158 policy: None,
159 types: Vec::new(),
160 extra: BTreeMap::new(),
161 }
162 }
163}
164
165#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
166pub struct RoomPolicy {
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub moderator_agent_ids: Vec<AgentId>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub max_speakers: Option<u32>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub observer_allowed: Option<bool>,
173 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
174 pub extra: BTreeMap<String, Value>,
175}
176
177impl RoomPolicy {
178 pub fn new() -> Self {
179 Self::default()
180 }
181}
182
183#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
185pub struct TypeDef {
186 #[serde(rename = "type")]
187 pub name: String,
188 pub kind: TypeKind,
189 pub title: String,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub description: Option<String>,
192 pub schema: Value,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub roles: Option<Vec<Role>>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub instructions: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub version: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub status: Option<TypeStatus>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub rate_hint: Option<u32>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub max_payload_hint: Option<u64>,
206 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
207 pub extra: BTreeMap<String, Value>,
208}
209
210impl TypeDef {
211 pub fn status(&self) -> TypeStatus {
212 self.status.unwrap_or(TypeStatus::Active)
213 }
214}
215
216#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
218pub struct TypeOverride {
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub roles: Option<Vec<Role>>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub instructions: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub status: Option<TypeStatus>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub rate_hint: Option<u32>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub max_payload_hint: Option<u64>,
229}
230
231#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
233pub struct PackImport {
234 #[serde(rename = "use", default, skip_serializing_if = "Option::is_none")]
235 pub use_pack: Option<String>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub pack: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub digest: Option<String>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub types: Option<Vec<String>>,
242 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
243 pub overrides: BTreeMap<String, TypeOverride>,
244}
245
246#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
248#[serde(untagged)]
249pub enum TypeDeclaration {
250 Def(TypeDef),
251 Import(PackImport),
252}
253
254#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
255pub struct Pack {
256 pub id: String,
257 pub title: String,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub description: Option<String>,
260 pub types: Vec<TypeDef>,
261 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
262 pub extra: BTreeMap<String, Value>,
263}
264
265#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
267pub struct PackDocument {
268 pub protocol: String,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub description: Option<String>,
271 pub packs: Vec<Pack>,
272}
273
274pub fn pack_map(document: &PackDocument) -> BTreeMap<String, Pack> {
276 document
277 .packs
278 .iter()
279 .map(|pack| (pack.id.clone(), pack.clone()))
280 .collect()
281}
282
283#[derive(Clone, Debug, Serialize, Deserialize)]
284pub struct ServerRecord<P = Value> {
285 pub room_id: String,
286 pub seq: u64,
287 #[serde(default)]
288 pub pre_hash: Option<String>,
289 pub hash: String,
290 pub received_at: i64,
291 pub envelope: Envelope<P>,
292}
293
294#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
295pub struct ServerRecordHashPayload {
296 pub room_id: String,
297 pub seq: u64,
298 pub pre_hash: Option<String>,
299 pub envelope_hash: String,
300 pub received_at: i64,
301}
302
303#[derive(Clone, Debug, Serialize, Deserialize)]
304pub struct RoomResponse {
305 pub id: String,
306 pub status: RoomState,
307 pub url: String,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub topic: Option<String>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub guidance: Option<String>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub visibility: Option<Visibility>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub start_time: Option<i64>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub end_time: Option<i64>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub policy: Option<RoomPolicy>,
320 #[serde(default, skip_serializing_if = "Vec::is_empty")]
322 pub types: Vec<TypeDef>,
323 pub seq: u64,
324 #[serde(default)]
325 pub pre_hash: Option<String>,
326 pub hash: String,
327 pub received_at: i64,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub envelope: Option<Envelope<RoomCreatePayload>>,
330}
331
332#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
333pub struct RoomJoinPayload {
334 pub request_id: String,
335 pub role: Role,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub perspective: Option<String>,
338 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
339 pub extra: BTreeMap<String, Value>,
340}
341
342#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
343pub struct RoomJoinRequestPayload {
344 pub requested_role: Role,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub perspective: Option<String>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub reason: Option<String>,
349 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
350 pub extra: BTreeMap<String, Value>,
351}
352
353impl RoomJoinRequestPayload {
354 pub fn new(requested_role: Role) -> Self {
355 Self {
356 requested_role,
357 perspective: None,
358 reason: None,
359 extra: BTreeMap::new(),
360 }
361 }
362}
363
364#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
365pub struct RoomJoinRequest {
366 pub id: String,
367 pub room_id: String,
368 pub applicant: AgentId,
369 pub requested_role: Role,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub approved_role: Option<Role>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub perspective: Option<String>,
374 pub status: JoinRequestStatus,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub request_reason: Option<String>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub review_reason: Option<String>,
379 pub created_at: i64,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub reviewed_by: Option<AgentId>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub reviewed_at: Option<i64>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub expires_at: Option<i64>,
386 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
387 pub extra: BTreeMap<String, Value>,
388}
389
390#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
391pub struct RoomJoinReviewPayload {
392 pub request_id: String,
393 pub member: AgentId,
394 pub decision: JoinDecision,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub role: Option<Role>,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub reason: Option<String>,
399 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
400 pub extra: BTreeMap<String, Value>,
401}
402
403#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
404pub struct RoleUpdatePayload {
405 pub member: AgentId,
406 pub role: Role,
407 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub reason: Option<String>,
409 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
410 pub extra: BTreeMap<String, Value>,
411}
412
413#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
415pub struct ReasonPayload {
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub reason: Option<String>,
418 #[serde(default, skip_serializing_if = "Vec::is_empty")]
419 pub references: Vec<String>,
420 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
421 pub extra: BTreeMap<String, Value>,
422}
423
424pub type RoomLeavePayload = ReasonPayload;
425pub type RoomClosePayload = ReasonPayload;
426pub type RoomCancelPayload = ReasonPayload;
427
428#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
429pub struct MessageCreatePayload {
430 pub content_type: String,
431 pub content: Value,
432 #[serde(default, skip_serializing_if = "Vec::is_empty")]
433 pub references: Vec<String>,
434 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
435 pub extra: BTreeMap<String, Value>,
436}
437
438impl MessageCreatePayload {
439 pub fn new(content_type: impl Into<String>, content: Value) -> Self {
440 Self {
441 content_type: content_type.into(),
442 content,
443 references: Vec::new(),
444 extra: BTreeMap::new(),
445 }
446 }
447
448 pub fn text(text: impl Into<String>) -> Self {
449 Self::new("text/plain", Value::String(text.into()))
450 }
451
452 pub fn markdown(markdown: impl Into<String>) -> Self {
453 Self::new("text/markdown", Value::String(markdown.into()))
454 }
455}
456
457#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
458pub struct ProfileResolverMetadata {
459 pub mode: String,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub service: Option<String>,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub protocol: Option<String>,
464}
465
466#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
467pub struct DiscourseProtocolDiscovery {
468 pub protocol: String,
469 pub host: String,
470 #[serde(default, skip_serializing_if = "Vec::is_empty")]
471 pub features: Vec<String>,
472 #[serde(default, skip_serializing_if = "Vec::is_empty")]
473 pub registered_packs: Vec<String>,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
475 pub profile: Option<ProfileResolverMetadata>,
476 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
477 pub endpoints: BTreeMap<String, String>,
478}
479
480#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
481pub struct ArchiveManifest {
482 pub protocol: String,
483 #[serde(rename = "type")]
484 pub kind: String,
485 pub host: String,
486 pub room_id: String,
487 pub url: String,
488 pub generated_at: i64,
489 pub event_count: u64,
490 pub first_seq: u64,
491 pub last_seq: u64,
492 pub last_hash: String,
493 pub events_sha3_256: String,
494 pub archive_root: String,
495 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
496 pub formats: BTreeMap<String, String>,
497 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
498 pub extra: BTreeMap<String, Value>,
499}
500
501pub fn room_create_event(
502 actor: AgentId,
503 created_at: i64,
504 nonce: u64,
505 payload: RoomCreatePayload,
506) -> Event<RoomCreatePayload> {
507 Event::new(
508 PROTOCOL,
509 event_type::ROOM_CREATE,
510 actor,
511 created_at,
512 nonce,
513 payload,
514 )
515}
516
517pub fn type_define_event(
518 actor: AgentId,
519 created_at: i64,
520 nonce: u64,
521 room_id: impl Into<String>,
522 declaration: TypeDeclaration,
523) -> Event<TypeDeclaration> {
524 Event::new(
525 PROTOCOL,
526 event_type::TYPE_DEFINE,
527 actor,
528 created_at,
529 nonce,
530 declaration,
531 )
532 .with_room_id(room_id)
533}
534
535pub fn discourse_event<P>(
536 kind: impl Into<String>,
537 actor: AgentId,
538 created_at: i64,
539 nonce: u64,
540 room_id: impl Into<String>,
541 payload: P,
542) -> Event<P> {
543 Event::new(PROTOCOL, kind, actor, created_at, nonce, payload).with_room_id(room_id)
544}
545
546pub fn is_builtin_event_type(event_type: &str) -> bool {
547 BUILTIN_EVENT_TYPES.contains(&event_type)
548}
549
550pub fn event_requires_room_id(event_type: &str) -> bool {
551 event_type != event_type::ROOM_CREATE
552}
553
554pub fn validate_discourse_envelope<P>(envelope: &Envelope<P>) -> Result<()>
555where
556 P: Serialize,
557{
558 verify_envelope(envelope)?;
559 let protocol = envelope.event.protocol.as_str();
560 if protocol != PROTOCOL {
561 return Err(SdkError::InvalidEventProtocol {
562 expected: PROTOCOL.to_owned(),
563 actual: envelope.event.protocol.clone(),
564 });
565 }
566 if envelope.event.kind == event_type::ROOM_CREATE {
567 if envelope.event.room_id.is_some() {
568 return Err(SdkError::InvalidPayload(
569 "room.create must not include room_id".to_owned(),
570 ));
571 }
572 } else if envelope.event.room_id.is_none() {
573 return Err(SdkError::MissingRoomId);
574 }
575 Ok(())
576}
577
578pub fn validate_room_path<P>(envelope: &Envelope<P>, path_room_id: &str) -> Result<()> {
579 if envelope.event.kind == event_type::ROOM_CREATE {
580 return match envelope.event.room_id.as_deref() {
581 None => Ok(()),
582 Some(_) => Err(SdkError::InvalidPayload(
583 "room.create must not include room_id".to_owned(),
584 )),
585 };
586 }
587 match envelope.event.room_id.as_deref() {
588 Some(actual) if actual == path_room_id => Ok(()),
589 Some(actual) => Err(SdkError::RoomIdMismatch {
590 expected: path_room_id.to_owned(),
591 actual: actual.to_owned(),
592 }),
593 None => Err(SdkError::MissingRoomId),
594 }
595}
596
597pub fn validate_custom_event_type_name(name: &str) -> Result<()> {
600 let segments: Vec<&str> = name.split('.').collect();
601 let valid_shape = segments.len() >= 2
602 && segments.iter().all(|segment| {
603 let mut chars = segment.chars();
604 matches!(chars.next(), Some(first) if first.is_ascii_lowercase() || first.is_ascii_digit())
605 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
606 });
607 if !valid_shape {
608 return Err(SdkError::InvalidPayload(format!(
609 "invalid event type name: {name}"
610 )));
611 }
612 if is_builtin_event_type(name) {
613 return Err(SdkError::InvalidPayload(format!(
614 "{name} is a built-in event type"
615 )));
616 }
617 if RESERVED_TYPE_PREFIXES
618 .iter()
619 .any(|prefix| name.starts_with(prefix))
620 {
621 return Err(SdkError::InvalidPayload(format!(
622 "{name} uses a reserved type prefix"
623 )));
624 }
625 Ok(())
626}
627
628pub fn validate_type_def(def: &TypeDef) -> Result<()> {
629 validate_custom_event_type_name(&def.name)?;
630 if def.title.trim().is_empty() {
631 return Err(SdkError::InvalidPayload(
632 "type definition title must not be empty".to_owned(),
633 ));
634 }
635 if !def.schema.is_object() {
636 return Err(SdkError::InvalidPayload(
637 "type definition schema must be a JSON Schema object".to_owned(),
638 ));
639 }
640 compile_schema(&def.schema)?;
641 if matches!(&def.roles, Some(roles) if roles.is_empty()) {
642 return Err(SdkError::InvalidPayload(
643 "type definition roles must not be empty".to_owned(),
644 ));
645 }
646 if matches!(def.rate_hint, Some(0)) || matches!(def.max_payload_hint, Some(0)) {
647 return Err(SdkError::InvalidPayload(
648 "type definition hints must be positive".to_owned(),
649 ));
650 }
651 Ok(())
652}
653
654pub fn validate_pack_import(import: &PackImport) -> Result<()> {
655 match (&import.use_pack, &import.pack, &import.digest) {
656 (Some(id), None, None) => {
657 if !is_registered_pack_id(id) {
658 return Err(SdkError::InvalidPayload(format!(
659 "invalid registered pack id: {id}"
660 )));
661 }
662 }
663 (None, Some(_), Some(digest)) => {
664 if digest.trim().is_empty() {
665 return Err(SdkError::InvalidPayload(
666 "external pack digest must not be empty".to_owned(),
667 ));
668 }
669 }
670 _ => {
671 return Err(SdkError::InvalidPayload(
672 "pack import requires either use, or pack with digest".to_owned(),
673 ));
674 }
675 }
676 if matches!(&import.types, Some(types) if types.is_empty()) {
677 return Err(SdkError::InvalidPayload(
678 "pack import types subset must not be empty".to_owned(),
679 ));
680 }
681 Ok(())
682}
683
684pub fn validate_type_declaration(declaration: &TypeDeclaration) -> Result<()> {
685 match declaration {
686 TypeDeclaration::Def(def) => validate_type_def(def),
687 TypeDeclaration::Import(import) => validate_pack_import(import),
688 }
689}
690
691fn is_registered_pack_id(id: &str) -> bool {
692 let Some(rest) = id.strip_prefix("adp:") else {
693 return false;
694 };
695 let Some((name, version)) = rest.split_once('/') else {
696 return false;
697 };
698 !name.is_empty()
699 && name
700 .chars()
701 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
702 && version.split('.').count() == 2
703 && version
704 .split('.')
705 .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
706}
707
708pub fn validate_room_create_payload(payload: &RoomCreatePayload) -> Result<()> {
709 if payload.topic.trim().is_empty() {
710 return Err(SdkError::InvalidPayload(
711 "room topic must not be empty".to_owned(),
712 ));
713 }
714 if payload.start_time >= payload.end_time {
715 return Err(SdkError::InvalidPayload(
716 "start_time must be before end_time".to_owned(),
717 ));
718 }
719 if let Some(policy) = &payload.policy {
720 if matches!(policy.max_speakers, Some(0)) {
721 return Err(SdkError::InvalidPayload(
722 "max_speakers must be a positive integer".to_owned(),
723 ));
724 }
725 }
726 for declaration in &payload.types {
727 validate_type_declaration(declaration)?;
728 }
729 Ok(())
730}
731
732pub fn validate_message_create_payload(payload: &MessageCreatePayload) -> Result<()> {
733 if payload.content_type.trim().is_empty() {
734 return Err(SdkError::InvalidPayload(
735 "content_type must not be empty".to_owned(),
736 ));
737 }
738 Ok(())
739}
740
741#[derive(Clone, Debug, Default)]
743pub struct TypeRegistry {
744 types: BTreeMap<String, TypeDef>,
745}
746
747impl TypeRegistry {
748 pub fn new() -> Self {
749 Self::default()
750 }
751
752 pub fn from_declarations(
755 declarations: &[TypeDeclaration],
756 packs: &BTreeMap<String, Pack>,
757 ) -> Result<Self> {
758 let mut registry = Self::new();
759 for declaration in declarations {
760 registry.apply(declaration, packs)?;
761 }
762 Ok(registry)
763 }
764
765 pub fn apply(
768 &mut self,
769 declaration: &TypeDeclaration,
770 packs: &BTreeMap<String, Pack>,
771 ) -> Result<()> {
772 match declaration {
773 TypeDeclaration::Def(def) => self.define(def.clone()),
774 TypeDeclaration::Import(import) => self.import(import, packs),
775 }
776 }
777
778 pub fn define(&mut self, def: TypeDef) -> Result<()> {
779 validate_type_def(&def)?;
780 self.types.insert(def.name.clone(), def);
781 Ok(())
782 }
783
784 fn import(&mut self, import: &PackImport, packs: &BTreeMap<String, Pack>) -> Result<()> {
785 validate_pack_import(import)?;
786 let reference = import
787 .use_pack
788 .as_deref()
789 .or(import.pack.as_deref())
790 .expect("validated pack import has a reference");
791 let pack = packs
792 .get(reference)
793 .ok_or_else(|| SdkError::PackUnavailable(reference.to_owned()))?;
794 let available: BTreeSet<&str> = pack.types.iter().map(|def| def.name.as_str()).collect();
795 if let Some(subset) = &import.types {
796 for name in subset {
797 if !available.contains(name.as_str()) {
798 return Err(SdkError::PackUnavailable(format!(
799 "type {name} is not in pack {reference}"
800 )));
801 }
802 }
803 }
804 for name in import.overrides.keys() {
805 let imported = import
806 .types
807 .as_ref()
808 .map(|subset| subset.contains(name))
809 .unwrap_or_else(|| available.contains(name.as_str()));
810 if !imported {
811 return Err(SdkError::InvalidPayload(format!(
812 "override target {name} is not imported from pack {reference}"
813 )));
814 }
815 }
816 for def in &pack.types {
817 if let Some(subset) = &import.types {
818 if !subset.contains(&def.name) {
819 continue;
820 }
821 }
822 let mut def = def.clone();
823 if let Some(over) = import.overrides.get(&def.name) {
824 if let Some(roles) = &over.roles {
825 def.roles = Some(roles.clone());
826 }
827 if let Some(instructions) = &over.instructions {
828 def.instructions = Some(instructions.clone());
829 }
830 if let Some(status) = over.status {
831 def.status = Some(status);
832 }
833 if let Some(rate_hint) = over.rate_hint {
834 def.rate_hint = Some(rate_hint);
835 }
836 if let Some(max_payload_hint) = over.max_payload_hint {
837 def.max_payload_hint = Some(max_payload_hint);
838 }
839 }
840 self.define(def)?;
841 }
842 Ok(())
843 }
844
845 pub fn get(&self, event_type: &str) -> Option<&TypeDef> {
846 self.types.get(event_type)
847 }
848
849 pub fn contains(&self, event_type: &str) -> bool {
850 self.types.contains_key(event_type)
851 }
852
853 pub fn len(&self) -> usize {
854 self.types.len()
855 }
856
857 pub fn is_empty(&self) -> bool {
858 self.types.is_empty()
859 }
860
861 pub fn definitions(&self) -> impl Iterator<Item = &TypeDef> {
862 self.types.values()
863 }
864
865 pub fn validate_payload(&self, event_type: &str, payload: &Value) -> Result<()> {
867 let def = self
868 .get(event_type)
869 .ok_or_else(|| SdkError::TypeNotDefined(event_type.to_owned()))?;
870 if def.status() == TypeStatus::Disabled {
871 return Err(SdkError::TypeDisabled(event_type.to_owned()));
872 }
873 let validator = compile_schema(&def.schema)?;
874 if let Err(error) = validator.validate(payload) {
875 return Err(SdkError::PayloadSchemaViolation(format!(
876 "{event_type}: {error}"
877 )));
878 }
879 Ok(())
880 }
881}
882
883pub fn validate_event_against_registry(
886 event_type: &str,
887 payload: &Value,
888 registry: &TypeRegistry,
889) -> Result<()> {
890 if is_builtin_event_type(event_type) {
891 return Ok(());
892 }
893 registry.validate_payload(event_type, payload)
894}
895
896fn compile_schema(schema: &Value) -> Result<jsonschema::Validator> {
897 jsonschema::options()
898 .with_draft(jsonschema::Draft::Draft202012)
899 .build(schema)
900 .map_err(|err| SdkError::InvalidPayload(format!("invalid type schema: {err}")))
901}
902
903pub fn verify_pack_digest(bytes: &[u8], digest: &str) -> Result<()> {
906 let (algorithm, expected) = digest.split_once(':').ok_or_else(|| {
907 SdkError::PackUnavailable(format!("invalid digest format: {digest}"))
908 })?;
909 let actual = match algorithm {
910 "sha256" => URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)),
911 "sha3-256" => URL_SAFE_NO_PAD.encode(Sha3_256::digest(bytes)),
912 _ => {
913 return Err(SdkError::PackUnavailable(format!(
914 "unsupported digest algorithm: {algorithm}"
915 )));
916 }
917 };
918 if actual == expected {
919 Ok(())
920 } else {
921 Err(SdkError::PackUnavailable(
922 "pack digest mismatch".to_owned(),
923 ))
924 }
925}
926
927pub fn server_record_hash_payload(
928 room_id: &str,
929 seq: u64,
930 pre_hash: Option<&str>,
931 envelope_hash: &str,
932 received_at: i64,
933) -> ServerRecordHashPayload {
934 ServerRecordHashPayload {
935 room_id: room_id.to_owned(),
936 seq,
937 pre_hash: pre_hash.map(str::to_owned),
938 envelope_hash: envelope_hash.to_owned(),
939 received_at,
940 }
941}
942
943pub fn server_record_hash(
944 room_id: &str,
945 seq: u64,
946 pre_hash: Option<&str>,
947 envelope_hash: &str,
948 received_at: i64,
949) -> Result<String> {
950 hash_canonical_json(&server_record_hash_payload(
951 room_id,
952 seq,
953 pre_hash,
954 envelope_hash,
955 received_at,
956 ))
957}
958
959pub fn build_server_record<P>(
960 room_id: impl Into<String>,
961 seq: u64,
962 pre_hash: Option<String>,
963 received_at: i64,
964 envelope: Envelope<P>,
965) -> Result<ServerRecord<P>> {
966 let room_id = room_id.into();
967 let hash = server_record_hash(
968 &room_id,
969 seq,
970 pre_hash.as_deref(),
971 &envelope.hash,
972 received_at,
973 )?;
974 Ok(ServerRecord {
975 room_id,
976 seq,
977 pre_hash,
978 hash,
979 received_at,
980 envelope,
981 })
982}
983
984pub fn verify_server_record<P>(record: &ServerRecord<P>) -> Result<()>
985where
986 P: Serialize,
987{
988 let expected = server_record_hash(
989 &record.room_id,
990 record.seq,
991 record.pre_hash.as_deref(),
992 &record.envelope.hash,
993 record.received_at,
994 )?;
995 if record.hash == expected {
996 Ok(())
997 } else {
998 Err(SdkError::InvalidEventHash {
999 expected,
1000 actual: record.hash.clone(),
1001 })
1002 }
1003}
1004
1005pub fn verify_server_record_chain<P>(records: &[ServerRecord<P>]) -> Result<()>
1006where
1007 P: Serialize,
1008{
1009 let mut previous: Option<&ServerRecord<P>> = None;
1010 for record in records {
1011 verify_server_record(record)?;
1012 if let Some(previous) = previous {
1013 if record.seq != previous.seq + 1 {
1014 return Err(SdkError::InvalidPayload(
1015 "seq must increase by 1".to_owned(),
1016 ));
1017 }
1018 if record.pre_hash.as_deref() != Some(previous.hash.as_str()) {
1019 return Err(SdkError::InvalidPayload("pre_hash mismatch".to_owned()));
1020 }
1021 } else if record.seq != 1 {
1022 return Err(SdkError::InvalidPayload("first seq must be 1".to_owned()));
1023 } else if record.pre_hash.is_some() {
1024 return Err(SdkError::InvalidPayload(
1025 "first pre_hash must be null".to_owned(),
1026 ));
1027 }
1028 previous = Some(record);
1029 }
1030 Ok(())
1031}
1032
1033pub fn archive_events_digest<P>(records: &[ServerRecord<P>]) -> Result<String>
1034where
1035 P: Serialize,
1036{
1037 hash_canonical_json(records)
1038}
1039
1040#[derive(Clone, Debug, Default, PartialEq, Eq)]
1042pub struct PermissionContext {
1043 pub role: Option<Role>,
1044 pub is_creator: bool,
1045 pub join_request_approved: bool,
1046}
1047
1048impl PermissionContext {
1049 pub fn for_role(role: Role) -> Self {
1050 Self {
1051 role: Some(role),
1052 ..Self::default()
1053 }
1054 }
1055
1056 pub fn creator(role: Option<Role>) -> Self {
1057 Self {
1058 role,
1059 is_creator: true,
1060 ..Self::default()
1061 }
1062 }
1063}
1064
1065pub fn default_kind_roles(kind: TypeKind) -> &'static [Role] {
1067 match kind {
1068 TypeKind::Message => &[Role::Moderator, Role::Speaker],
1069 TypeKind::Signal => &[Role::Moderator, Role::Speaker, Role::Observer],
1070 TypeKind::Control => &[Role::Moderator],
1071 }
1072}
1073
1074pub fn can_submit_event(
1077 event_type: &str,
1078 context: &PermissionContext,
1079 registry: &TypeRegistry,
1080) -> bool {
1081 match event_type {
1082 event_type::ROOM_CREATE => true,
1083 event_type::ROOM_JOIN => context.join_request_approved,
1084 event_type::ROOM_LEAVE => context.is_creator || context.role.is_some(),
1085 event_type::ROOM_JOIN_REVIEW
1086 | event_type::ROOM_MEMBER_ROLE_UPDATE
1087 | event_type::ROOM_CLOSE
1088 | event_type::ROOM_CANCEL
1089 | event_type::TYPE_DEFINE => {
1090 context.is_creator || context.role == Some(Role::Moderator)
1091 }
1092 event_type::MESSAGE_CREATE => {
1093 context.is_creator
1094 || matches!(context.role, Some(Role::Moderator) | Some(Role::Speaker))
1095 }
1096 custom => {
1097 let Some(def) = registry.get(custom) else {
1098 return false;
1099 };
1100 if def.status() == TypeStatus::Disabled {
1101 return false;
1102 }
1103 if context.is_creator {
1104 return true;
1105 }
1106 let Some(role) = context.role else {
1107 return false;
1108 };
1109 match &def.roles {
1110 Some(roles) => roles.contains(&role),
1111 None => default_kind_roles(def.kind).contains(&role),
1112 }
1113 }
1114 }
1115}
1116
1117pub fn can_write_in_state(event_type: &str, state: RoomState) -> bool {
1118 match state {
1119 RoomState::Scheduled => matches!(
1120 event_type,
1121 event_type::ROOM_JOIN
1122 | event_type::ROOM_JOIN_REVIEW
1123 | event_type::ROOM_MEMBER_ROLE_UPDATE
1124 | event_type::ROOM_LEAVE
1125 | event_type::TYPE_DEFINE
1126 | event_type::ROOM_CANCEL
1127 ),
1128 RoomState::Active => {
1129 event_type != event_type::ROOM_CREATE && event_type != event_type::ROOM_CANCEL
1130 }
1131 RoomState::Ended | RoomState::Cancelled => false,
1132 }
1133}
1134
1135pub fn can_accept_room_write(
1136 event_type: &str,
1137 state: RoomState,
1138 permission: &PermissionContext,
1139 registry: &TypeRegistry,
1140) -> bool {
1141 can_submit_event(event_type, permission, registry) && can_write_in_state(event_type, state)
1142}
1143
1144pub fn validate_room_write(
1145 event_type: &str,
1146 state: RoomState,
1147 permission: &PermissionContext,
1148 registry: &TypeRegistry,
1149) -> Result<()> {
1150 if can_accept_room_write(event_type, state, permission, registry) {
1151 Ok(())
1152 } else {
1153 Err(SdkError::PermissionDenied)
1154 }
1155}
1156
1157fn hash_canonical_json<T>(value: &T) -> Result<String>
1158where
1159 T: Serialize + ?Sized,
1160{
1161 let bytes = serde_jcs::to_vec(value).map_err(|err| SdkError::CanonicalJson(err.to_string()))?;
1162 let digest = Sha3_256::digest(bytes);
1163 Ok(URL_SAFE_NO_PAD.encode(digest))
1164}
1165
1166#[cfg(test)]
1167mod tests {
1168 use super::*;
1169 use crate::identity::AgentSigner;
1170 use serde_json::json;
1171
1172 fn registered_packs() -> BTreeMap<String, Pack> {
1173 let raw = std::fs::read_to_string(concat!(
1174 env!("CARGO_MANIFEST_DIR"),
1175 "/../../docs/protocols/agent-discourse/1.0.packs.json"
1176 ))
1177 .expect("read registered packs");
1178 let document: PackDocument = serde_json::from_str(&raw).expect("parse registered packs");
1179 assert_eq!(document.protocol, PROTOCOL);
1180 pack_map(&document)
1181 }
1182
1183 fn finding_def() -> TypeDef {
1184 TypeDef {
1185 name: "review.finding".to_owned(),
1186 kind: TypeKind::Message,
1187 title: "Review finding".to_owned(),
1188 description: None,
1189 schema: json!({
1190 "type": "object",
1191 "required": ["severity", "summary"],
1192 "properties": {
1193 "severity": {"type": "string", "enum": ["low", "medium", "high"]},
1194 "summary": {"type": "string", "minLength": 1}
1195 },
1196 "additionalProperties": false
1197 }),
1198 roles: None,
1199 instructions: None,
1200 version: None,
1201 status: None,
1202 rate_hint: None,
1203 max_payload_hint: None,
1204 extra: BTreeMap::new(),
1205 }
1206 }
1207
1208 #[test]
1209 fn validates_room_create_without_room_id() {
1210 let signer = AgentSigner::from_seed([14; 32]);
1211 let payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1212 let envelope = signer
1213 .sign_event(room_create_event(signer.agent_id(), 100, 1, payload))
1214 .unwrap();
1215
1216 validate_discourse_envelope(&envelope).unwrap();
1217 validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").unwrap();
1218 }
1219
1220 #[test]
1221 fn rejects_room_create_with_room_id() {
1222 let signer = AgentSigner::from_seed([14; 32]);
1223 let payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1224 let event = room_create_event(signer.agent_id(), 100, 1, payload)
1225 .with_room_id("d8ftedhpqhsusbg001tg");
1226 let envelope = signer.sign_event(event).unwrap();
1227
1228 assert!(matches!(
1229 validate_discourse_envelope(&envelope),
1230 Err(SdkError::InvalidPayload(_))
1231 ));
1232 assert!(validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").is_err());
1233 }
1234
1235 #[test]
1236 fn rejects_room_event_without_room_id() {
1237 let signer = AgentSigner::from_seed([15; 32]);
1238 let event = Event::new(
1239 PROTOCOL,
1240 event_type::MESSAGE_CREATE,
1241 signer.agent_id(),
1242 100,
1243 1,
1244 MessageCreatePayload::text("hello"),
1245 );
1246 let envelope = signer.sign_event(event).unwrap();
1247
1248 assert!(matches!(
1249 validate_discourse_envelope(&envelope),
1250 Err(SdkError::MissingRoomId)
1251 ));
1252 }
1253
1254 #[test]
1255 fn role_serde_matches_spec() {
1256 assert_eq!(serde_json::to_string(&Role::Speaker).unwrap(), "\"speaker\"");
1257 assert!(serde_json::from_str::<Role>("\"expert\"").is_err());
1258 assert!(serde_json::from_str::<Role>("\"participant\"").is_err());
1259 }
1260
1261 #[test]
1262 fn validates_custom_event_type_names() {
1263 validate_custom_event_type_name("review.finding").unwrap();
1264 validate_custom_event_type_name("poll.vote").unwrap();
1265 assert!(validate_custom_event_type_name("freeform").is_err());
1266 assert!(validate_custom_event_type_name("room.custom").is_err());
1267 assert!(validate_custom_event_type_name("type.new").is_err());
1268 assert!(validate_custom_event_type_name("message.create").is_err());
1269 assert!(validate_custom_event_type_name("Bad.Name").is_err());
1270 }
1271
1272 #[test]
1273 fn materializes_registry_from_packs_and_inline_defs() {
1274 let packs = registered_packs();
1275 let declarations = vec![
1276 TypeDeclaration::Import(PackImport {
1277 use_pack: Some(pack_id::REACTIONS.to_owned()),
1278 ..PackImport::default()
1279 }),
1280 TypeDeclaration::Import(PackImport {
1281 use_pack: Some(pack_id::DELIBERATION.to_owned()),
1282 overrides: BTreeMap::from([(
1283 "poll.vote".to_owned(),
1284 TypeOverride {
1285 roles: Some(vec![Role::Moderator, Role::Speaker, Role::Observer]),
1286 ..TypeOverride::default()
1287 },
1288 )]),
1289 ..PackImport::default()
1290 }),
1291 TypeDeclaration::Def(finding_def()),
1292 ];
1293 let registry = TypeRegistry::from_declarations(&declarations, &packs).unwrap();
1294
1295 assert_eq!(registry.len(), 6);
1296 assert!(registry.contains("reaction.create"));
1297 assert!(registry.contains("poll.create"));
1298 assert!(registry.contains("review.finding"));
1299 let vote = registry.get("poll.vote").unwrap();
1300 assert_eq!(
1301 vote.roles.as_deref(),
1302 Some([Role::Moderator, Role::Speaker, Role::Observer].as_slice())
1303 );
1304
1305 let subset = TypeDeclaration::Import(PackImport {
1306 use_pack: Some(pack_id::DELIBERATION.to_owned()),
1307 types: Some(vec!["poll.create".to_owned(), "poll.vote".to_owned()]),
1308 ..PackImport::default()
1309 });
1310 let registry = TypeRegistry::from_declarations(&[subset], &packs).unwrap();
1311 assert_eq!(registry.len(), 2);
1312 assert!(!registry.contains("question.create"));
1313 }
1314
1315 #[test]
1316 fn rejects_bad_pack_imports() {
1317 let packs = registered_packs();
1318 let unknown = TypeDeclaration::Import(PackImport {
1319 use_pack: Some("adp:unknown/1.0".to_owned()),
1320 ..PackImport::default()
1321 });
1322 assert!(matches!(
1323 TypeRegistry::from_declarations(&[unknown], &packs),
1324 Err(SdkError::PackUnavailable(_))
1325 ));
1326
1327 let bad_override = TypeDeclaration::Import(PackImport {
1328 use_pack: Some(pack_id::REACTIONS.to_owned()),
1329 overrides: BTreeMap::from([("poll.vote".to_owned(), TypeOverride::default())]),
1330 ..PackImport::default()
1331 });
1332 assert!(TypeRegistry::from_declarations(&[bad_override], &packs).is_err());
1333
1334 let both = PackImport {
1335 use_pack: Some(pack_id::REACTIONS.to_owned()),
1336 pack: Some("https://example.com/p.json".to_owned()),
1337 digest: Some("sha256:abc".to_owned()),
1338 ..PackImport::default()
1339 };
1340 assert!(validate_pack_import(&both).is_err());
1341 }
1342
1343 #[test]
1344 fn latest_type_definition_wins() {
1345 let mut registry = TypeRegistry::new();
1346 registry.define(finding_def()).unwrap();
1347 let mut redefined = finding_def();
1348 redefined.status = Some(TypeStatus::Disabled);
1349 registry.define(redefined).unwrap();
1350 assert_eq!(
1351 registry.get("review.finding").unwrap().status(),
1352 TypeStatus::Disabled
1353 );
1354 }
1355
1356 #[test]
1357 fn validates_custom_payloads_against_pack_schemas() {
1358 let packs = registered_packs();
1359 let registry = TypeRegistry::from_declarations(
1360 &[TypeDeclaration::Import(PackImport {
1361 use_pack: Some(pack_id::DELIBERATION.to_owned()),
1362 ..PackImport::default()
1363 })],
1364 &packs,
1365 )
1366 .unwrap();
1367
1368 let hash = "GDt8oHZQfQ3jl5ZUfyNxKZu07yAJdDYuaw_jf_JjLYs";
1369 registry
1370 .validate_payload(
1371 "poll.vote",
1372 &json!({"poll_event_id": hash, "option_ids": ["a"]}),
1373 )
1374 .unwrap();
1375 assert!(matches!(
1376 registry.validate_payload("poll.vote", &json!({"poll_event_id": hash})),
1377 Err(SdkError::PayloadSchemaViolation(_))
1378 ));
1379 assert!(matches!(
1380 registry.validate_payload("turn.update", &json!({})),
1381 Err(SdkError::TypeNotDefined(_))
1382 ));
1383
1384 let mut disabled = TypeRegistry::new();
1385 let mut def = finding_def();
1386 def.status = Some(TypeStatus::Disabled);
1387 disabled.define(def).unwrap();
1388 assert!(matches!(
1389 disabled.validate_payload("review.finding", &json!({"severity": "high", "summary": "s"})),
1390 Err(SdkError::TypeDisabled(_))
1391 ));
1392 }
1393
1394 #[test]
1395 fn applies_kind_based_permissions() {
1396 let packs = registered_packs();
1397 let registry = TypeRegistry::from_declarations(
1398 &[
1399 TypeDeclaration::Import(PackImport {
1400 use_pack: Some(pack_id::REACTIONS.to_owned()),
1401 ..PackImport::default()
1402 }),
1403 TypeDeclaration::Import(PackImport {
1404 use_pack: Some(pack_id::DELIBERATION.to_owned()),
1405 overrides: BTreeMap::from([(
1406 "poll.vote".to_owned(),
1407 TypeOverride {
1408 roles: Some(vec![Role::Moderator, Role::Speaker, Role::Observer]),
1409 ..TypeOverride::default()
1410 },
1411 )]),
1412 ..PackImport::default()
1413 }),
1414 TypeDeclaration::Import(PackImport {
1415 use_pack: Some(pack_id::CURATION.to_owned()),
1416 ..PackImport::default()
1417 }),
1418 ],
1419 &packs,
1420 )
1421 .unwrap();
1422
1423 let observer = PermissionContext::for_role(Role::Observer);
1424 let speaker = PermissionContext::for_role(Role::Speaker);
1425 let moderator = PermissionContext::for_role(Role::Moderator);
1426 let creator = PermissionContext::creator(Some(Role::Observer));
1427
1428 assert!(can_submit_event("reaction.create", &observer, ®istry));
1430 assert!(can_submit_event("poll.vote", &observer, ®istry));
1432 assert!(can_submit_event("resource.add", &speaker, ®istry));
1434 assert!(!can_submit_event("resource.add", &observer, ®istry));
1435 assert!(can_submit_event("graph.update", &moderator, ®istry));
1437 assert!(!can_submit_event("graph.update", &speaker, ®istry));
1438 assert!(can_submit_event("graph.update", &creator, ®istry));
1440 assert!(can_submit_event(event_type::MESSAGE_CREATE, &creator, ®istry));
1441 assert!(!can_submit_event("session.offer", &speaker, ®istry));
1443
1444 assert!(can_submit_event(event_type::ROOM_JOIN_REVIEW, &moderator, ®istry));
1446 assert!(!can_submit_event(event_type::ROOM_JOIN_REVIEW, &speaker, ®istry));
1447 assert!(can_submit_event(event_type::ROOM_MEMBER_ROLE_UPDATE, &moderator, ®istry));
1448 assert!(can_submit_event(event_type::ROOM_CANCEL, &moderator, ®istry));
1449 assert!(can_submit_event(event_type::TYPE_DEFINE, &moderator, ®istry));
1450 assert!(!can_submit_event(event_type::TYPE_DEFINE, &speaker, ®istry));
1451 assert!(can_submit_event(event_type::MESSAGE_CREATE, &speaker, ®istry));
1452 assert!(!can_submit_event(event_type::MESSAGE_CREATE, &observer, ®istry));
1453 assert!(can_submit_event(event_type::ROOM_LEAVE, &observer, ®istry));
1454 assert!(!can_submit_event(event_type::ROOM_JOIN, &observer, ®istry));
1455 let approved = PermissionContext {
1456 join_request_approved: true,
1457 ..PermissionContext::default()
1458 };
1459 assert!(can_submit_event(event_type::ROOM_JOIN, &approved, ®istry));
1460 }
1461
1462 #[test]
1463 fn applies_state_restrictions() {
1464 let registry = TypeRegistry::new();
1465 let speaker = PermissionContext::for_role(Role::Speaker);
1466 let moderator = PermissionContext::for_role(Role::Moderator);
1467
1468 assert!(can_accept_room_write(
1469 event_type::MESSAGE_CREATE,
1470 RoomState::Active,
1471 &speaker,
1472 ®istry
1473 ));
1474 assert!(!can_accept_room_write(
1475 event_type::MESSAGE_CREATE,
1476 RoomState::Scheduled,
1477 &speaker,
1478 ®istry
1479 ));
1480 assert!(can_write_in_state(event_type::ROOM_JOIN_REVIEW, RoomState::Scheduled));
1482 assert!(can_write_in_state(event_type::ROOM_MEMBER_ROLE_UPDATE, RoomState::Scheduled));
1483 assert!(can_write_in_state(event_type::ROOM_LEAVE, RoomState::Scheduled));
1484 assert!(can_write_in_state(event_type::TYPE_DEFINE, RoomState::Scheduled));
1485 assert!(can_write_in_state(event_type::ROOM_CANCEL, RoomState::Scheduled));
1486 assert!(!can_write_in_state(event_type::ROOM_CLOSE, RoomState::Scheduled));
1487 assert!(can_accept_room_write(
1488 event_type::TYPE_DEFINE,
1489 RoomState::Scheduled,
1490 &moderator,
1491 ®istry
1492 ));
1493 assert!(!can_write_in_state("reaction.create", RoomState::Ended));
1495 assert!(!can_write_in_state(event_type::ROOM_LEAVE, RoomState::Ended));
1496 assert!(!can_write_in_state(event_type::ROOM_JOIN, RoomState::Cancelled));
1497 assert!(can_write_in_state(event_type::ROOM_CLOSE, RoomState::Active));
1499 assert!(!can_write_in_state(event_type::ROOM_CANCEL, RoomState::Active));
1500 }
1501
1502 #[test]
1503 fn validates_room_creation_payloads() {
1504 let mut payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1505 payload.policy = Some(RoomPolicy {
1506 max_speakers: Some(2),
1507 ..RoomPolicy::default()
1508 });
1509 payload.guidance = Some("Cite sources.".to_owned());
1510 payload.types = vec![
1511 TypeDeclaration::Import(PackImport {
1512 use_pack: Some(pack_id::REACTIONS.to_owned()),
1513 ..PackImport::default()
1514 }),
1515 TypeDeclaration::Def(finding_def()),
1516 ];
1517 validate_room_create_payload(&payload).unwrap();
1518
1519 let empty_topic = RoomCreatePayload::new(" ", Visibility::Public, 1000, 2000);
1520 assert!(validate_room_create_payload(&empty_topic).is_err());
1521
1522 let invalid_time = RoomCreatePayload::new("Research room", Visibility::Public, 2000, 1000);
1523 assert!(validate_room_create_payload(&invalid_time).is_err());
1524
1525 let mut zero_speakers =
1526 RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1527 zero_speakers.policy = Some(RoomPolicy {
1528 max_speakers: Some(0),
1529 ..RoomPolicy::default()
1530 });
1531 assert!(validate_room_create_payload(&zero_speakers).is_err());
1532
1533 let mut reserved = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1534 let mut bad_def = finding_def();
1535 bad_def.name = "room.custom".to_owned();
1536 reserved.types = vec![TypeDeclaration::Def(bad_def)];
1537 assert!(validate_room_create_payload(&reserved).is_err());
1538 }
1539
1540 #[test]
1541 fn verifies_pack_digests() {
1542 let bytes = b"pack document bytes";
1543 let digest = format!("sha256:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)));
1544 verify_pack_digest(bytes, &digest).unwrap();
1545 assert!(matches!(
1546 verify_pack_digest(b"tampered", &digest),
1547 Err(SdkError::PackUnavailable(_))
1548 ));
1549 assert!(verify_pack_digest(bytes, "md5:abc").is_err());
1550 assert!(verify_pack_digest(bytes, "not-a-digest").is_err());
1551 }
1552
1553 #[test]
1554 fn builds_and_verifies_server_record_chains() {
1555 let signer = AgentSigner::from_seed([18; 32]);
1556 let envelope1 = signer
1557 .sign_event(Event::new(
1558 PROTOCOL,
1559 event_type::ROOM_CREATE,
1560 signer.agent_id(),
1561 100,
1562 1,
1563 json!({
1564 "topic": "Research room",
1565 "visibility": "public",
1566 "start_time": 1000,
1567 "end_time": 2000
1568 }),
1569 ))
1570 .unwrap();
1571 let record1 = build_server_record("room123", 1, None, 110, envelope1).unwrap();
1572
1573 let envelope2 = signer
1574 .sign_event(discourse_event(
1575 event_type::MESSAGE_CREATE,
1576 signer.agent_id(),
1577 120,
1578 2,
1579 "room123",
1580 json!({"content_type": "text/plain", "content": "hello"}),
1581 ))
1582 .unwrap();
1583 let record2 =
1584 build_server_record("room123", 2, Some(record1.hash.clone()), 130, envelope2).unwrap();
1585
1586 assert_eq!(
1587 record1.hash,
1588 server_record_hash("room123", 1, None, &record1.envelope.hash, 110).unwrap()
1589 );
1590 verify_server_record(&record1).unwrap();
1591 verify_server_record_chain(&[record1.clone(), record2.clone()]).unwrap();
1592 assert_eq!(
1593 archive_events_digest(&[record1.clone(), record2.clone()])
1594 .unwrap()
1595 .len(),
1596 43
1597 );
1598 assert!(verify_server_record_chain(&[record2]).is_err());
1599
1600 let broken = ServerRecord {
1601 pre_hash: Some("bad".to_owned()),
1602 ..record1
1603 };
1604 assert!(verify_server_record_chain(&[broken]).is_err());
1605 }
1606
1607 #[test]
1608 fn type_declaration_serde_round_trips() {
1609 let inline: TypeDeclaration = serde_json::from_value(json!({
1610 "type": "review.finding",
1611 "kind": "message",
1612 "title": "Review finding",
1613 "schema": {"type": "object"}
1614 }))
1615 .unwrap();
1616 assert!(matches!(inline, TypeDeclaration::Def(_)));
1617
1618 let import: TypeDeclaration = serde_json::from_value(json!({
1619 "use": "adp:reactions/1.0",
1620 "overrides": {"reaction.create": {"status": "deprecated"}}
1621 }))
1622 .unwrap();
1623 match &import {
1624 TypeDeclaration::Import(import) => {
1625 assert_eq!(import.use_pack.as_deref(), Some("adp:reactions/1.0"));
1626 }
1627 TypeDeclaration::Def(_) => panic!("expected pack import"),
1628 }
1629 validate_type_declaration(&import).unwrap();
1630 }
1631}