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 sha3::{Digest, Sha3_256};
16
17use crate::error::{Result, SdkError};
18use crate::identity::{
19 validate_event_fields, validate_origin, verify_envelope, AgentId, Envelope, Event,
20 ListResponse, MAX_SAFE_NONCE,
21};
22
23pub const PROTOCOL: &str = "agent-discourse/1.0";
24
25pub mod event_type {
27 pub const ROOM_CREATE: &str = "room.create";
28 pub const ROOM_UPDATE: &str = "room.update";
29 pub const ROOM_JOIN: &str = "room.join";
30 pub const ROOM_JOIN_REQUEST: &str = "room.join.request";
31 pub const ROOM_JOIN_REVIEW: &str = "room.join.review";
32 pub const ROOM_LEAVE: &str = "room.leave";
33 pub const ROOM_MEMBER_ROLE_UPDATE: &str = "room.member.role.update";
34 pub const ROOM_MEMBER_REMOVE: &str = "room.member.remove";
35 pub const ROOM_CLOSE: &str = "room.close";
36 pub const ROOM_CANCEL: &str = "room.cancel";
37 pub const TYPE_DEFINE: &str = "type.define";
38 pub const MESSAGE_CREATE: &str = "message.create";
39}
40
41pub const BUILTIN_EVENT_TYPES: [&str; 12] = [
42 event_type::ROOM_CREATE,
43 event_type::ROOM_UPDATE,
44 event_type::ROOM_JOIN,
45 event_type::ROOM_JOIN_REQUEST,
46 event_type::ROOM_JOIN_REVIEW,
47 event_type::ROOM_LEAVE,
48 event_type::ROOM_MEMBER_ROLE_UPDATE,
49 event_type::ROOM_MEMBER_REMOVE,
50 event_type::ROOM_CLOSE,
51 event_type::ROOM_CANCEL,
52 event_type::TYPE_DEFINE,
53 event_type::MESSAGE_CREATE,
54];
55
56pub const MEMBERSHIP_EVENT_TYPES: [&str; 5] = [
60 event_type::ROOM_JOIN,
61 event_type::ROOM_JOIN_REVIEW,
62 event_type::ROOM_LEAVE,
63 event_type::ROOM_MEMBER_ROLE_UPDATE,
64 event_type::ROOM_MEMBER_REMOVE,
65];
66
67pub const CONTRACT_EVENT_TYPES: [&str; 4] = [
71 event_type::ROOM_UPDATE,
72 event_type::ROOM_CLOSE,
73 event_type::ROOM_CANCEL,
74 event_type::TYPE_DEFINE,
75];
76
77pub const DISCOURSE_ERROR_CODES: [&str; 18] = [
79 "room_not_found",
80 "room_not_active",
81 "host_mismatch",
82 "approval_required",
83 "join_request_not_found",
84 "join_request_not_pending",
85 "member_banned",
86 "max_speakers_exceeded",
87 "membership_required",
88 "room_head_mismatch",
89 "base_record_mismatch",
90 "agent_status_not_found",
91 "type_not_defined",
92 "type_disabled",
93 "type_conflict",
94 "invalid_type_schema",
95 "payload_schema_violation",
96 "pack_unavailable",
97];
98
99pub const MAX_MENTIONS: usize = 32;
101
102pub const RESERVED_TYPE_PREFIXES: [&str; 3] = ["room.", "type.", "message."];
104
105pub mod pack_id {
107 pub const REACTIONS: &str = "adp:reactions/1.0";
108 pub const DELIBERATION: &str = "adp:deliberation/1.0";
109 pub const CURATION: &str = "adp:curation/1.0";
110 pub const MODERATION: &str = "adp:moderation/1.0";
111 pub const REALTIME: &str = "adp:realtime/1.0";
112
113 pub const REGISTERED: [&str; 5] = [REACTIONS, DELIBERATION, CURATION, MODERATION, REALTIME];
114}
115
116#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
117#[serde(rename_all = "snake_case")]
118pub enum RoomState {
119 Scheduled,
120 Active,
121 Ended,
122 Cancelled,
123}
124
125#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
126#[serde(rename_all = "snake_case")]
127pub enum Visibility {
128 Public,
129 Restricted,
130 Private,
131}
132
133#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
134#[serde(rename_all = "snake_case")]
135pub enum Role {
136 Moderator,
137 Speaker,
138 Observer,
139}
140
141#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
143#[serde(rename_all = "snake_case")]
144pub enum TypeKind {
145 Message,
146 Signal,
147 Control,
148}
149
150#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
151#[serde(rename_all = "snake_case")]
152pub enum TypeStatus {
153 Active,
154 Deprecated,
155 Disabled,
156}
157
158#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
164#[serde(rename_all = "snake_case")]
165pub enum RecordClass {
166 Genesis,
167 Contract,
168 Message,
169 Signal,
170 Control,
171}
172
173pub fn builtin_event_class(event_type: &str) -> Option<RecordClass> {
176 match event_type {
177 event_type::ROOM_CREATE => Some(RecordClass::Genesis),
178 event_type::ROOM_UPDATE
179 | event_type::ROOM_CLOSE
180 | event_type::ROOM_CANCEL
181 | event_type::TYPE_DEFINE => Some(RecordClass::Contract),
182 event_type::ROOM_JOIN
183 | event_type::ROOM_JOIN_REVIEW
184 | event_type::ROOM_LEAVE
185 | event_type::ROOM_MEMBER_ROLE_UPDATE
186 | event_type::ROOM_MEMBER_REMOVE => Some(RecordClass::Signal),
187 event_type::MESSAGE_CREATE => Some(RecordClass::Message),
188 _ => None,
189 }
190}
191
192fn kind_class(kind: TypeKind) -> RecordClass {
193 match kind {
194 TypeKind::Message => RecordClass::Message,
195 TypeKind::Signal => RecordClass::Signal,
196 TypeKind::Control => RecordClass::Control,
197 }
198}
199
200pub fn record_class(event_type: &str, types: &[TypeDef]) -> Option<RecordClass> {
203 if let Some(class) = builtin_event_class(event_type) {
204 return Some(class);
205 }
206 if is_builtin_event_type(event_type) {
207 return None;
208 }
209 types
210 .iter()
211 .find(|def| def.name == event_type)
212 .map(|def| kind_class(def.kind))
213}
214
215pub fn event_advances_room_head(event_type: &str, registry: &TypeRegistry) -> bool {
219 event_type_advances_head(
220 event_type,
221 ®istry.definitions().cloned().collect::<Vec<_>>(),
222 )
223}
224
225pub fn event_type_advances_head(event_type: &str, types: &[TypeDef]) -> bool {
227 !matches!(
228 record_class(event_type, types),
229 Some(RecordClass::Message | RecordClass::Signal)
230 )
231}
232
233pub fn event_requires_room_head(event_type: &str, types: &[TypeDef]) -> bool {
238 match record_class(event_type, types) {
239 Some(RecordClass::Message | RecordClass::Control) => true,
240 Some(_) => false,
241 None => !is_builtin_event_type(event_type),
242 }
243}
244
245pub fn validate_room_base(
252 event_type: &str,
253 types: &[TypeDef],
254 base_seq: u64,
255 base_hash: &str,
256 anchor_hash: Option<&str>,
257 head_seq: u64,
258) -> Result<()> {
259 if anchor_hash != Some(base_hash) {
260 return Err(SdkError::protocol(
261 "base_record_mismatch",
262 format!("base {base_seq} does not name an accepted record of this room"),
263 ));
264 }
265 if event_requires_room_head(event_type, types) && base_seq < head_seq {
266 return Err(SdkError::protocol(
267 "room_head_mismatch",
268 format!("base {base_seq} is before the room head {head_seq}"),
269 ));
270 }
271 Ok(())
272}
273
274#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
275#[serde(rename_all = "snake_case")]
276pub enum JoinRequestStatus {
277 Pending,
278 Approved,
279 Rejected,
280 Expired,
281}
282
283#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
284#[serde(rename_all = "snake_case")]
285pub enum JoinDecision {
286 Approve,
287 Reject,
288}
289
290#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
291pub struct RoomCreatePayload {
292 pub host: String,
294 pub topic: String,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub agenda: Option<String>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub guidance: Option<String>,
299 pub visibility: Visibility,
300 pub start_time: i64,
301 pub end_time: i64,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub tags: Option<Vec<String>>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub language: Option<String>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub policy: Option<RoomPolicy>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub types: Option<Vec<TypeDeclaration>>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub extra: Option<BTreeMap<String, Value>>,
312}
313
314impl RoomCreatePayload {
315 pub fn new(
316 host: impl Into<String>,
317 topic: impl Into<String>,
318 visibility: Visibility,
319 start_time: i64,
320 end_time: i64,
321 ) -> Self {
322 Self {
323 host: host.into(),
324 topic: topic.into(),
325 agenda: None,
326 guidance: None,
327 visibility,
328 start_time,
329 end_time,
330 tags: None,
331 language: None,
332 policy: None,
333 types: None,
334 extra: None,
335 }
336 }
337}
338
339#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
340#[serde(deny_unknown_fields)]
341pub struct RoomPolicy {
342 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub invites: Option<BTreeMap<AgentId, Role>>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub open_roles: Option<Vec<Role>>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub max_speakers: Option<u32>,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub extra: Option<BTreeMap<String, Value>>,
352}
353
354impl RoomPolicy {
355 pub fn new() -> Self {
356 Self::default()
357 }
358}
359
360#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
366#[serde(deny_unknown_fields)]
367pub struct RoomUpdatePayload {
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub topic: Option<String>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub agenda: Option<String>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub guidance: Option<String>,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub tags: Option<Vec<String>>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub language: Option<String>,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub policy: Option<RoomPolicy>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub start_time: Option<i64>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub end_time: Option<i64>,
384}
385
386impl RoomUpdatePayload {
387 pub fn is_empty(&self) -> bool {
388 self.topic.is_none()
389 && self.agenda.is_none()
390 && self.guidance.is_none()
391 && self.tags.is_none()
392 && self.language.is_none()
393 && self.policy.is_none()
394 && self.start_time.is_none()
395 && self.end_time.is_none()
396 }
397}
398
399#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
401pub struct RoomMemberRemovePayload {
402 pub member: AgentId,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub ban: Option<bool>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub reason: Option<String>,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub references: Option<Vec<String>>,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub extra: Option<BTreeMap<String, Value>>,
412}
413
414impl RoomMemberRemovePayload {
415 pub fn new(member: AgentId) -> Self {
416 Self {
417 member,
418 ban: None,
419 reason: None,
420 references: None,
421 extra: None,
422 }
423 }
424
425 pub fn banning(&self) -> bool {
426 self.ban.unwrap_or(false)
427 }
428}
429
430#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
432#[serde(deny_unknown_fields)]
433pub struct TypeDef {
434 #[serde(rename = "type")]
435 pub name: String,
436 pub kind: TypeKind,
437 pub title: String,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub description: Option<String>,
440 pub schema: Value,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub roles: Option<Vec<Role>>,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub instructions: Option<String>,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub status: Option<TypeStatus>,
448 #[serde(default, skip_serializing_if = "Option::is_none")]
449 pub rate_hint: Option<u32>,
450 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub max_payload_hint: Option<u64>,
452 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub extra: Option<BTreeMap<String, Value>>,
456}
457
458impl TypeDef {
459 pub fn status(&self) -> TypeStatus {
460 self.status.unwrap_or(TypeStatus::Active)
461 }
462}
463
464#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
466pub struct TypeOverride {
467 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub roles: Option<Vec<Role>>,
469 #[serde(default, skip_serializing_if = "Option::is_none")]
470 pub instructions: Option<String>,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub status: Option<TypeStatus>,
473 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub rate_hint: Option<u32>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub max_payload_hint: Option<u64>,
477}
478
479#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
481pub struct PackImport {
482 #[serde(rename = "use", default, skip_serializing_if = "Option::is_none")]
483 pub use_pack: Option<String>,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub pack: Option<String>,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub digest: Option<String>,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub types: Option<Vec<String>>,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub overrides: Option<BTreeMap<String, TypeOverride>>,
494}
495
496#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
498#[serde(untagged)]
499pub enum TypeDeclaration {
500 Def(TypeDef),
501 Import(PackImport),
502}
503
504#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
505pub struct Pack {
506 pub id: String,
507 pub title: String,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
509 pub description: Option<String>,
510 pub types: Vec<TypeDef>,
511 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
512 pub extra: BTreeMap<String, Value>,
513}
514
515#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
517pub struct PackDocument {
518 pub protocol: String,
519 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub description: Option<String>,
521 pub packs: Vec<Pack>,
522}
523
524pub fn pack_map(document: &PackDocument) -> BTreeMap<String, Pack> {
526 document
527 .packs
528 .iter()
529 .map(|pack| (pack.id.clone(), pack.clone()))
530 .collect()
531}
532
533#[derive(Clone, Debug, Serialize, Deserialize)]
534pub struct ServerRecord<P = Value> {
535 pub room_id: String,
536 pub seq: u64,
537 #[serde(default)]
538 pub pre_hash: Option<String>,
539 pub hash: String,
540 pub accepted_at: i64,
541 pub envelope: Envelope<P>,
542}
543
544#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
546#[serde(deny_unknown_fields)]
547pub struct RedactedEnvelope {
548 pub hash: String,
549 pub redacted: bool,
550 #[serde(rename = "type")]
551 pub kind: String,
552}
553
554#[derive(Clone, Debug, Serialize, Deserialize)]
556pub struct RedactedServerRecord {
557 pub room_id: String,
558 pub seq: u64,
559 #[serde(default)]
560 pub pre_hash: Option<String>,
561 pub hash: String,
562 pub accepted_at: i64,
563 pub envelope: RedactedEnvelope,
564}
565
566#[derive(Clone, Debug, Serialize, Deserialize)]
568#[serde(untagged)]
569#[allow(clippy::large_enum_variant)]
570pub enum ArchiveRecord {
571 Redacted(RedactedServerRecord),
572 Signed(ServerRecord),
573}
574
575impl ArchiveRecord {
576 pub fn room_id(&self) -> &str {
577 match self {
578 Self::Signed(r) => &r.room_id,
579 Self::Redacted(r) => &r.room_id,
580 }
581 }
582 pub fn seq(&self) -> u64 {
583 match self {
584 Self::Signed(r) => r.seq,
585 Self::Redacted(r) => r.seq,
586 }
587 }
588 pub fn pre_hash(&self) -> Option<&str> {
589 match self {
590 Self::Signed(r) => r.pre_hash.as_deref(),
591 Self::Redacted(r) => r.pre_hash.as_deref(),
592 }
593 }
594 pub fn hash(&self) -> &str {
595 match self {
596 Self::Signed(r) => &r.hash,
597 Self::Redacted(r) => &r.hash,
598 }
599 }
600 pub fn accepted_at(&self) -> i64 {
601 match self {
602 Self::Signed(r) => r.accepted_at,
603 Self::Redacted(r) => r.accepted_at,
604 }
605 }
606 pub fn envelope_hash(&self) -> &str {
607 match self {
608 Self::Signed(r) => &r.envelope.hash,
609 Self::Redacted(r) => &r.envelope.hash,
610 }
611 }
612 pub fn event_type(&self) -> &str {
614 match self {
615 Self::Signed(r) => &r.envelope.event.kind,
616 Self::Redacted(r) => &r.envelope.kind,
617 }
618 }
619 pub fn is_redacted(&self) -> bool {
620 matches!(self, Self::Redacted(_))
621 }
622}
623
624impl From<ServerRecord> for ArchiveRecord {
625 fn from(record: ServerRecord) -> Self {
626 Self::Signed(record)
627 }
628}
629
630impl From<RedactedServerRecord> for ArchiveRecord {
631 fn from(record: RedactedServerRecord) -> Self {
632 Self::Redacted(record)
633 }
634}
635
636pub type RoomEventsResponse = ListResponse<ArchiveRecord>;
637
638#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
639pub struct ServerRecordHashPayload {
640 pub room_id: String,
641 pub seq: u64,
642 pub pre_hash: Option<String>,
643 pub envelope_hash: String,
644 pub accepted_at: i64,
645}
646
647#[derive(Clone, Debug, Serialize, Deserialize)]
648pub struct RoomResponse {
649 pub id: String,
650 pub status: RoomState,
651 pub url: String,
652 #[serde(default, skip_serializing_if = "Option::is_none")]
653 pub creator: Option<AgentId>,
654 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub created_at: Option<i64>,
656 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub topic: Option<String>,
658 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub agenda: Option<String>,
660 #[serde(default, skip_serializing_if = "Option::is_none")]
661 pub guidance: Option<String>,
662 #[serde(default, skip_serializing_if = "Option::is_none")]
663 pub visibility: Option<Visibility>,
664 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub start_time: Option<i64>,
666 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub end_time: Option<i64>,
668 #[serde(default, skip_serializing_if = "Vec::is_empty")]
669 pub tags: Vec<String>,
670 #[serde(default, skip_serializing_if = "Option::is_none")]
671 pub language: Option<String>,
672 #[serde(default, skip_serializing_if = "Option::is_none")]
673 pub policy: Option<RoomPolicy>,
674 #[serde(default, skip_serializing_if = "Vec::is_empty")]
676 pub types: Vec<TypeDef>,
677 pub seq: u64,
678 #[serde(default)]
679 pub pre_hash: Option<String>,
680 pub hash: String,
681 pub accepted_at: i64,
682 #[serde(default, skip_serializing_if = "Option::is_none")]
684 pub head: Option<RoomHead>,
685 #[serde(default, skip_serializing_if = "Option::is_none")]
686 pub envelope: Option<Envelope<RoomCreatePayload>>,
687}
688
689#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
690pub struct RoomHead {
691 pub seq: u64,
692 pub hash: String,
693}
694
695#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
697#[serde(deny_unknown_fields)]
698pub struct RoomJoinPayload {
699 pub role: Role,
700 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub perspective: Option<String>,
702}
703
704#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
706#[serde(deny_unknown_fields)]
707pub struct RoomJoinRequestPayload {
708 pub role: Role,
709 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub perspective: Option<String>,
711 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub reason: Option<String>,
713 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub extra: Option<BTreeMap<String, Value>>,
715}
716
717impl RoomJoinRequestPayload {
718 pub fn new(role: Role) -> Self {
719 Self {
720 role,
721 perspective: None,
722 reason: None,
723 extra: None,
724 }
725 }
726}
727
728#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
730pub struct RoomJoinRequest {
731 pub id: String,
733 pub request: Envelope<RoomJoinRequestPayload>,
734 pub status: JoinRequestStatus,
735 pub expires_at: i64,
736 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub reviewed_by: Option<AgentId>,
738 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub reviewed_at: Option<i64>,
740 #[serde(default, skip_serializing_if = "Option::is_none")]
741 pub review_event_id: Option<String>,
742}
743
744#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
745pub struct AgentStatusInput {
746 pub state: String,
747 #[serde(default, skip_serializing_if = "Option::is_none")]
748 pub summary: Option<String>,
749 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub seen_seq: Option<u64>,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
752 pub seen_hash: Option<String>,
753 #[serde(default, skip_serializing_if = "Option::is_none")]
754 pub claim_id: Option<String>,
755 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub activity: Option<String>,
757 #[serde(default, skip_serializing_if = "Option::is_none")]
759 pub expires_at: Option<i64>,
760 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
761 pub extra: BTreeMap<String, Value>,
762}
763
764impl AgentStatusInput {
765 pub fn new(state: impl Into<String>) -> Self {
766 Self {
767 state: state.into(),
768 summary: None,
769 seen_seq: None,
770 seen_hash: None,
771 claim_id: None,
772 activity: None,
773 expires_at: None,
774 extra: BTreeMap::new(),
775 }
776 }
777
778 pub fn with_expires_at(mut self, expires_at: i64) -> Self {
779 self.expires_at = Some(expires_at);
780 self
781 }
782}
783
784#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
785pub struct AgentStatus {
786 pub room_id: String,
787 pub agent_id: AgentId,
788 pub state: String,
789 #[serde(default, skip_serializing_if = "Option::is_none")]
790 pub summary: Option<String>,
791 #[serde(default, skip_serializing_if = "Option::is_none")]
792 pub seen_seq: Option<u64>,
793 #[serde(default, skip_serializing_if = "Option::is_none")]
794 pub seen_hash: Option<String>,
795 #[serde(default, skip_serializing_if = "Option::is_none")]
796 pub claim_id: Option<String>,
797 #[serde(default, skip_serializing_if = "Option::is_none")]
798 pub activity: Option<String>,
799 pub expires_at: i64,
800 pub updated_at: i64,
801 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
802 pub extra: BTreeMap<String, Value>,
803}
804
805pub type AgentStatusListResponse = ListResponse<AgentStatus>;
806
807#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
808#[serde(deny_unknown_fields)]
809pub struct RoomJoinReviewPayload {
810 pub request: Envelope<RoomJoinRequestPayload>,
812 pub decision: JoinDecision,
813 #[serde(default, skip_serializing_if = "Option::is_none")]
815 pub role: Option<Role>,
816 #[serde(default, skip_serializing_if = "Option::is_none")]
817 pub reason: Option<String>,
818 #[serde(default, skip_serializing_if = "Option::is_none")]
819 pub extra: Option<BTreeMap<String, Value>>,
820}
821
822#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
823pub struct RoleUpdatePayload {
824 pub member: AgentId,
825 pub role: Role,
826 #[serde(default, skip_serializing_if = "Option::is_none")]
827 pub reason: Option<String>,
828 #[serde(default, skip_serializing_if = "Option::is_none")]
829 pub extra: Option<BTreeMap<String, Value>>,
830}
831
832#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
834pub struct ReasonPayload {
835 #[serde(default, skip_serializing_if = "Option::is_none")]
836 pub reason: Option<String>,
837 #[serde(default, skip_serializing_if = "Option::is_none")]
838 pub references: Option<Vec<String>>,
839 #[serde(default, skip_serializing_if = "Option::is_none")]
840 pub extra: Option<BTreeMap<String, Value>>,
841}
842
843pub type RoomLeavePayload = ReasonPayload;
844pub type RoomClosePayload = ReasonPayload;
845pub type RoomCancelPayload = ReasonPayload;
846
847#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
848pub struct MessageCreatePayload {
849 pub content_type: String,
850 pub content: Value,
852 #[serde(default, skip_serializing_if = "Option::is_none")]
853 pub references: Option<Vec<String>>,
854 #[serde(default, skip_serializing_if = "Option::is_none")]
855 pub extra: Option<BTreeMap<String, Value>>,
856}
857
858impl MessageCreatePayload {
859 pub fn new(content_type: impl Into<String>, content: Value) -> Self {
860 Self {
861 content_type: content_type.into(),
862 content,
863 references: None,
864 extra: None,
865 }
866 }
867
868 pub fn text(text: impl Into<String>) -> Self {
869 Self::new("text/plain", Value::String(text.into()))
870 }
871
872 pub fn markdown(markdown: impl Into<String>) -> Self {
873 Self::new("text/markdown", Value::String(markdown.into()))
874 }
875}
876
877#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
881#[serde(deny_unknown_fields)]
882pub struct ProfileResolverMetadata {
883 pub service: String,
884 #[serde(default, skip_serializing_if = "Option::is_none")]
885 pub required: Option<bool>,
886}
887
888#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
889pub struct DiscourseProtocolDiscovery {
890 pub protocol: String,
891 pub service: String,
892 #[serde(default, skip_serializing_if = "Vec::is_empty")]
893 pub features: Vec<String>,
894 #[serde(default, skip_serializing_if = "Vec::is_empty")]
895 pub registered_packs: Vec<String>,
896 #[serde(default, skip_serializing_if = "Option::is_none")]
897 pub profile: Option<ProfileResolverMetadata>,
898 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
899 pub endpoints: BTreeMap<String, String>,
900}
901
902#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
903pub struct ArchiveManifest {
904 pub protocol: String,
905 #[serde(rename = "type")]
906 pub kind: String,
907 pub room_id: String,
908 pub url: String,
909 pub generated_at: i64,
910 pub last_seq: u64,
911 pub last_hash: String,
913 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
915 pub formats: BTreeMap<String, String>,
916 #[serde(default, skip_serializing_if = "Vec::is_empty")]
918 pub packs: Vec<ArchivedPack>,
919 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
920 pub extra: BTreeMap<String, Value>,
921}
922
923#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
925#[serde(deny_unknown_fields)]
926pub struct ArchivedPack {
927 pub pack: String,
928 pub digest: String,
929 pub url: String,
930}
931
932pub fn room_create_event(
933 actor: AgentId,
934 created_at: i64,
935 nonce: u64,
936 payload: RoomCreatePayload,
937) -> Event<RoomCreatePayload> {
938 Event::new(
939 PROTOCOL,
940 event_type::ROOM_CREATE,
941 actor,
942 created_at,
943 nonce,
944 payload,
945 )
946}
947
948pub fn room_join_request_event(
951 actor: AgentId,
952 created_at: i64,
953 nonce: u64,
954 room_id: impl Into<String>,
955 payload: RoomJoinRequestPayload,
956) -> Event<RoomJoinRequestPayload> {
957 Event::new(
958 PROTOCOL,
959 event_type::ROOM_JOIN_REQUEST,
960 actor,
961 created_at,
962 nonce,
963 payload,
964 )
965 .with_room_id(room_id)
966}
967
968pub fn type_define_event(
969 actor: AgentId,
970 created_at: i64,
971 nonce: u64,
972 room_id: impl Into<String>,
973 base_seq: u64,
974 base_hash: impl Into<String>,
975 declaration: TypeDeclaration,
976) -> Event<TypeDeclaration> {
977 Event::new(
978 PROTOCOL,
979 event_type::TYPE_DEFINE,
980 actor,
981 created_at,
982 nonce,
983 declaration,
984 )
985 .with_room_id(room_id)
986 .with_room_head(base_seq, base_hash)
987}
988
989#[allow(clippy::too_many_arguments)]
990pub fn discourse_event<P>(
991 kind: impl Into<String>,
992 actor: AgentId,
993 created_at: i64,
994 nonce: u64,
995 room_id: impl Into<String>,
996 base_seq: u64,
997 base_hash: impl Into<String>,
998 payload: P,
999) -> Event<P> {
1000 Event::new(PROTOCOL, kind, actor, created_at, nonce, payload)
1001 .with_room_id(room_id)
1002 .with_room_head(base_seq, base_hash)
1003}
1004
1005pub fn is_builtin_event_type(event_type: &str) -> bool {
1006 BUILTIN_EVENT_TYPES.contains(&event_type)
1007}
1008
1009pub fn event_requires_room_id(event_type: &str) -> bool {
1010 event_type != event_type::ROOM_CREATE
1011}
1012
1013pub fn event_requires_base(event_type: &str) -> bool {
1015 event_type != event_type::ROOM_CREATE && event_type != event_type::ROOM_JOIN_REQUEST
1016}
1017
1018pub fn validate_room_id(room_id: &str) -> Result<()> {
1020 let valid = (1..=64).contains(&room_id.len())
1021 && room_id
1022 .bytes()
1023 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
1024 if valid {
1025 Ok(())
1026 } else {
1027 Err(invalid_event("room_id must match [A-Za-z0-9_-]{1,64}"))
1028 }
1029}
1030
1031fn invalid_event(message: impl Into<String>) -> SdkError {
1032 SdkError::protocol("invalid_event", message)
1033}
1034
1035pub fn validate_discourse_envelope<P>(envelope: &Envelope<P>) -> Result<()>
1036where
1037 P: Serialize,
1038{
1039 verify_envelope(envelope)?;
1040 validate_discourse_event_fields(&envelope.event)
1041}
1042
1043pub fn validate_discourse_event_fields<P>(event: &Event<P>) -> Result<()> {
1045 if event.protocol != PROTOCOL {
1046 return Err(SdkError::InvalidEventProtocol {
1047 expected: PROTOCOL.to_owned(),
1048 actual: event.protocol.clone(),
1049 });
1050 }
1051 match event.kind.as_str() {
1052 event_type::ROOM_CREATE => validate_event_fields(event, &[]),
1053 event_type::ROOM_JOIN_REQUEST => {
1054 validate_event_fields(event, &["room_id"])?;
1055 validate_room_id(event.room_id.as_deref().ok_or(SdkError::MissingRoomId)?)
1056 }
1057 _ => {
1058 validate_event_fields(event, &["room_id", "base_seq", "base_hash", "mentions"])?;
1059 validate_room_id(event.room_id.as_deref().ok_or(SdkError::MissingRoomId)?)?;
1060 validate_room_head_precondition(event)?;
1061 validate_mentions(event.mentions())
1062 }
1063 }
1064}
1065
1066pub fn validate_room_path<P>(envelope: &Envelope<P>, path_room_id: &str) -> Result<()> {
1067 validate_discourse_event_fields(&envelope.event)?;
1068 if envelope.event.kind == event_type::ROOM_CREATE {
1069 return Ok(());
1070 }
1071 match envelope.event.room_id.as_deref() {
1072 Some(actual) if actual == path_room_id => Ok(()),
1073 Some(actual) => Err(SdkError::RoomIdMismatch {
1074 expected: path_room_id.to_owned(),
1075 actual: actual.to_owned(),
1076 }),
1077 None => Err(SdkError::MissingRoomId),
1078 }
1079}
1080
1081fn validate_room_head_precondition<P>(event: &Event<P>) -> Result<()> {
1082 match (event.base_seq, event.base_hash.as_deref()) {
1083 (Some(seq), Some(hash)) if seq > 0 && seq <= MAX_SAFE_NONCE && !hash.trim().is_empty() => {
1084 Ok(())
1085 }
1086 (Some(0), _) => Err(invalid_event(
1087 "base_seq must be a positive safe JSON integer",
1088 )),
1089 (Some(_), Some(hash)) if hash.trim().is_empty() => {
1090 Err(invalid_event("base_hash must not be empty"))
1091 }
1092 (Some(seq), _) if seq > MAX_SAFE_NONCE => {
1093 Err(invalid_event("base_seq must be a safe JSON integer"))
1094 }
1095 _ => Err(invalid_event("room event requires base_seq and base_hash")),
1096 }
1097}
1098
1099fn validate_mentions(mentions: &[AgentId]) -> Result<()> {
1100 if mentions.len() > MAX_MENTIONS {
1101 return Err(invalid_event(format!(
1102 "mentions must not exceed {MAX_MENTIONS} entries"
1103 )));
1104 }
1105 let unique: BTreeSet<&AgentId> = mentions.iter().collect();
1106 if unique.len() != mentions.len() {
1107 return Err(invalid_event("mentions must be unique"));
1108 }
1109 Ok(())
1110}
1111
1112pub fn validate_custom_event_type_name(name: &str) -> Result<()> {
1115 let segments: Vec<&str> = name.split('.').collect();
1116 let valid_shape = segments.len() >= 2 && segments.iter().all(|segment| {
1117 let mut chars = segment.chars();
1118 matches!(chars.next(), Some(first) if first.is_ascii_lowercase() || first.is_ascii_digit())
1119 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
1120 });
1121 if !valid_shape {
1122 return Err(invalid_event(format!("invalid event type name: {name}")));
1123 }
1124 if is_builtin_event_type(name) {
1125 return Err(invalid_event(format!("{name} is a built-in event type")));
1126 }
1127 if RESERVED_TYPE_PREFIXES
1128 .iter()
1129 .any(|prefix| name.starts_with(prefix))
1130 {
1131 return Err(invalid_event(format!("{name} uses a reserved type prefix")));
1132 }
1133 Ok(())
1134}
1135
1136pub fn validate_type_def(def: &TypeDef) -> Result<()> {
1137 validate_custom_event_type_name(&def.name)?;
1138 if def.title.trim().is_empty() {
1139 return Err(invalid_event("type definition title must not be empty"));
1140 }
1141 if !def.schema.is_object() {
1142 return Err(SdkError::protocol(
1143 "invalid_type_schema",
1144 "type definition schema must be a JSON Schema object",
1145 ));
1146 }
1147 validate_type_schema_profile(&def.schema)?;
1148 compile_schema(&def.schema)?;
1149 if matches!(&def.roles, Some(roles) if roles.is_empty()) {
1150 return Err(invalid_event("type definition roles must not be empty"));
1151 }
1152 if matches!(def.rate_hint, Some(0)) || matches!(def.max_payload_hint, Some(0)) {
1153 return Err(invalid_event("type definition hints must be positive"));
1154 }
1155 Ok(())
1156}
1157
1158pub fn validate_content_digest(digest: &str) -> Result<()> {
1161 let valid = digest.len() == 43
1162 && digest
1163 .bytes()
1164 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
1165 if valid {
1166 Ok(())
1167 } else {
1168 Err(invalid_event(
1169 "external pack digest must be the base64url SHA3-256 of the pack bytes",
1170 ))
1171 }
1172}
1173
1174pub fn validate_pack_import(import: &PackImport) -> Result<()> {
1175 match (&import.use_pack, &import.pack, &import.digest) {
1176 (Some(id), None, None) => {
1177 if !is_registered_pack_id(id) {
1178 return Err(invalid_event(format!("invalid registered pack id: {id}")));
1179 }
1180 }
1181 (None, Some(pack), Some(digest)) => {
1182 if !pack.starts_with("https://") {
1183 return Err(invalid_event("external pack must be an HTTPS URL"));
1184 }
1185 validate_content_digest(digest)?;
1186 }
1187 _ => {
1188 return Err(invalid_event(
1189 "pack import requires either use, or pack with digest",
1190 ));
1191 }
1192 }
1193 if let Some(types) = &import.types {
1194 if types.is_empty() {
1195 return Err(invalid_event("pack import types subset must not be empty"));
1196 }
1197 if types.iter().collect::<BTreeSet<_>>().len() != types.len() {
1198 return Err(SdkError::protocol(
1199 "type_conflict",
1200 "pack import types subset has duplicates",
1201 ));
1202 }
1203 }
1204 Ok(())
1205}
1206
1207pub fn validate_type_declaration(declaration: &TypeDeclaration) -> Result<()> {
1208 match declaration {
1209 TypeDeclaration::Def(def) => validate_type_def(def),
1210 TypeDeclaration::Import(import) => validate_pack_import(import),
1211 }
1212}
1213
1214fn is_registered_pack_id(id: &str) -> bool {
1215 let Some(rest) = id.strip_prefix("adp:") else {
1216 return false;
1217 };
1218 let Some((name, version)) = rest.split_once('/') else {
1219 return false;
1220 };
1221 !name.is_empty()
1222 && name
1223 .chars()
1224 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1225 && version.split('.').count() == 2
1226 && version
1227 .split('.')
1228 .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
1229}
1230
1231const FORBIDDEN_SCHEMA_KEYWORDS: [&str; 5] = [
1234 "$dynamicRef",
1235 "$dynamicAnchor",
1236 "$recursiveRef",
1237 "$recursiveAnchor",
1238 "$vocabulary",
1239];
1240const SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
1241const SUBSCHEMA_KEYWORDS: [&str; 11] = [
1242 "items",
1243 "additionalProperties",
1244 "not",
1245 "if",
1246 "then",
1247 "else",
1248 "contains",
1249 "propertyNames",
1250 "unevaluatedItems",
1251 "unevaluatedProperties",
1252 "additionalItems",
1253];
1254const SUBSCHEMA_ARRAY_KEYWORDS: [&str; 4] = ["allOf", "anyOf", "oneOf", "prefixItems"];
1255const SUBSCHEMA_MAP_KEYWORDS: [&str; 5] = [
1256 "properties",
1257 "patternProperties",
1258 "$defs",
1259 "definitions",
1260 "dependentSchemas",
1261];
1262
1263fn walk_schema(
1264 schema: &Value,
1265 visit: &mut dyn FnMut(&serde_json::Map<String, Value>) -> Result<()>,
1266) -> Result<()> {
1267 let Some(node) = schema.as_object() else {
1268 return Ok(());
1269 };
1270 visit(node)?;
1271 for key in SUBSCHEMA_KEYWORDS {
1272 if let Some(child) = node.get(key) {
1273 walk_schema(child, visit)?;
1274 }
1275 }
1276 for key in SUBSCHEMA_ARRAY_KEYWORDS {
1277 if let Some(Value::Array(items)) = node.get(key) {
1278 for item in items {
1279 walk_schema(item, visit)?;
1280 }
1281 }
1282 }
1283 for key in SUBSCHEMA_MAP_KEYWORDS {
1284 if let Some(Value::Object(map)) = node.get(key) {
1285 for value in map.values() {
1286 walk_schema(value, visit)?;
1287 }
1288 }
1289 }
1290 Ok(())
1291}
1292
1293fn invalid_schema(message: impl Into<String>) -> SdkError {
1294 SdkError::protocol("invalid_type_schema", message)
1295}
1296
1297pub fn validate_type_schema_profile(schema: &Value) -> Result<()> {
1301 walk_schema(schema, &mut |node| {
1302 for keyword in FORBIDDEN_SCHEMA_KEYWORDS {
1303 if node.contains_key(keyword) {
1304 return Err(invalid_schema(format!("{keyword} is not allowed")));
1305 }
1306 }
1307 if let Some(dialect) = node.get("$schema") {
1308 if dialect.as_str() != Some(SCHEMA_DIALECT) {
1309 return Err(invalid_schema("$schema must be the draft 2020-12 dialect"));
1310 }
1311 }
1312 if let Some(reference) = node.get("$ref") {
1313 if !reference.as_str().is_some_and(|r| r.starts_with('#')) {
1314 return Err(invalid_schema("$ref must be a fragment inside the schema"));
1315 }
1316 }
1317 if let Some(pattern) = node.get("pattern") {
1318 let pattern = pattern
1319 .as_str()
1320 .ok_or_else(|| invalid_schema("pattern must be a string"))?;
1321 validate_portable_pattern(pattern)?;
1322 }
1323 if let Some(Value::Object(properties)) = node.get("patternProperties") {
1324 for pattern in properties.keys() {
1325 validate_portable_pattern(pattern)?;
1326 }
1327 }
1328 Ok(())
1329 })
1330}
1331
1332pub fn validate_portable_pattern(pattern: &str) -> Result<()> {
1336 let reject =
1337 |reason: &str| invalid_schema(format!("pattern {pattern:?} is not portable: {reason}"));
1338 let mut body = pattern.strip_prefix('^').unwrap_or(pattern);
1339 if body.ends_with('$') && !body.ends_with("\\$") {
1340 body = &body[..body.len() - 1];
1341 }
1342 let chars: Vec<char> = body.chars().collect();
1343 const SINGLE_ESCAPES: &str = "()*+-.?[\\]^{|}nrt";
1344 let mut i = 0;
1345 let mut depth = 0usize;
1346 let mut can_quantify = false;
1347 let read_escape = |i: &mut usize| -> Result<()> {
1348 match chars.get(*i + 1) {
1349 None => Err(reject("dangling escape")),
1350 Some(next) if !SINGLE_ESCAPES.contains(*next) => {
1351 Err(reject(&format!("escape \\{next}")))
1352 }
1353 Some(_) => {
1354 *i += 2;
1355 Ok(())
1356 }
1357 }
1358 };
1359 while i < chars.len() {
1360 match chars[i] {
1361 '\\' => {
1362 read_escape(&mut i)?;
1363 can_quantify = true;
1364 }
1365 '[' => {
1366 i += 1;
1367 if chars.get(i) == Some(&'^') {
1368 i += 1;
1369 }
1370 if chars.get(i) == Some(&']') {
1372 return Err(reject("empty character class"));
1373 }
1374 while i < chars.len() && chars[i] != ']' {
1375 match chars[i] {
1376 '\\' => read_escape(&mut i)?,
1377 '[' => return Err(reject("nested character class")),
1378 _ => i += 1,
1379 }
1380 }
1381 if chars.get(i) != Some(&']') {
1382 return Err(reject("unterminated character class"));
1383 }
1384 i += 1;
1385 can_quantify = true;
1386 }
1387 '(' => {
1388 if chars.get(i + 1) == Some(&'?') {
1389 return Err(reject("group modifiers"));
1390 }
1391 depth += 1;
1392 i += 1;
1393 can_quantify = false;
1394 }
1395 ')' => {
1396 if depth == 0 {
1397 return Err(reject("unbalanced parenthesis"));
1398 }
1399 depth -= 1;
1400 i += 1;
1401 can_quantify = true;
1402 }
1403 '|' => {
1404 i += 1;
1405 can_quantify = false;
1406 }
1407 '*' | '+' | '?' => {
1408 if !can_quantify {
1409 return Err(reject("quantifier without operand"));
1410 }
1411 i += 1;
1412 if matches!(chars.get(i), Some('?' | '+')) {
1413 return Err(reject("lazy or possessive quantifier"));
1414 }
1415 can_quantify = false;
1416 }
1417 '{' => {
1418 if !can_quantify {
1419 return Err(reject("quantifier without operand"));
1420 }
1421 let mut j = i + 1;
1422 let start = j;
1423 while j < chars.len() && chars[j].is_ascii_digit() {
1424 j += 1;
1425 }
1426 if j == start {
1427 return Err(reject("malformed quantifier"));
1428 }
1429 if chars.get(j) == Some(&',') {
1430 j += 1;
1431 while j < chars.len() && chars[j].is_ascii_digit() {
1432 j += 1;
1433 }
1434 }
1435 if chars.get(j) != Some(&'}') {
1436 return Err(reject("malformed quantifier"));
1437 }
1438 i = j + 1;
1439 if matches!(chars.get(i), Some('?' | '+')) {
1440 return Err(reject("lazy or possessive quantifier"));
1441 }
1442 can_quantify = false;
1443 }
1444 '.' => return Err(reject("'.' outside a character class")),
1445 '^' | '$' => return Err(reject("anchor inside the pattern")),
1446 '}' | ']' => return Err(reject(&format!("unescaped {}", chars[i]))),
1447 _ => {
1448 i += 1;
1449 can_quantify = true;
1450 }
1451 }
1452 }
1453 if depth != 0 {
1454 return Err(reject("unbalanced parenthesis"));
1455 }
1456 Ok(())
1457}
1458
1459const ROLE_ORDER: [Role; 3] = [Role::Moderator, Role::Speaker, Role::Observer];
1460
1461pub fn validate_room_policy(policy: Option<&RoomPolicy>) -> Result<()> {
1463 let Some(policy) = policy else {
1464 return Ok(());
1465 };
1466 if matches!(policy.max_speakers, Some(0)) {
1467 return Err(invalid_event("max_speakers must be a positive integer"));
1468 }
1469 if let Some(open_roles) = &policy.open_roles {
1470 if open_roles.iter().collect::<BTreeSet<_>>().len() != open_roles.len() {
1471 return Err(invalid_event("open_roles must be a list of unique roles"));
1472 }
1473 if open_roles.contains(&Role::Moderator) {
1474 return Err(invalid_event("open_roles cannot contain moderator"));
1475 }
1476 }
1477 Ok(())
1478}
1479
1480pub fn validate_room_visibility_policy(
1484 visibility: Visibility,
1485 policy: Option<&RoomPolicy>,
1486) -> Result<()> {
1487 let open = policy
1488 .and_then(|p| p.open_roles.as_ref())
1489 .is_some_and(|roles| !roles.is_empty());
1490 if visibility == Visibility::Private && open {
1491 return Err(invalid_event("a private room cannot have open roles"));
1492 }
1493 Ok(())
1494}
1495
1496pub fn effective_open_roles(visibility: Visibility, policy: Option<&RoomPolicy>) -> Vec<Role> {
1500 if let Some(open_roles) = policy.and_then(|p| p.open_roles.clone()) {
1501 return open_roles;
1502 }
1503 if visibility == Visibility::Public {
1504 vec![Role::Observer]
1505 } else {
1506 Vec::new()
1507 }
1508}
1509
1510pub fn can_join_directly(
1514 visibility: Visibility,
1515 policy: Option<&RoomPolicy>,
1516 actor: &AgentId,
1517 role: Role,
1518) -> bool {
1519 if policy
1520 .and_then(|p| p.invites.as_ref())
1521 .and_then(|invites| invites.get(actor))
1522 == Some(&role)
1523 {
1524 return true;
1525 }
1526 effective_open_roles(visibility, policy).contains(&role)
1527}
1528
1529pub fn validate_room_create_payload(payload: &RoomCreatePayload) -> Result<()> {
1530 if validate_origin(&payload.host).is_err() {
1531 return Err(invalid_event("room.create host must be an HTTPS origin"));
1532 }
1533 if payload.topic.trim().is_empty() {
1534 return Err(invalid_event("room topic must not be empty"));
1535 }
1536 if payload.start_time >= payload.end_time {
1537 return Err(invalid_event("start_time must be before end_time"));
1538 }
1539 validate_room_policy(payload.policy.as_ref())?;
1540 validate_room_visibility_policy(payload.visibility, payload.policy.as_ref())?;
1541 for declaration in payload.types.iter().flatten() {
1542 validate_type_declaration(declaration)?;
1543 }
1544 Ok(())
1545}
1546
1547pub fn validate_room_create_host(payload: &RoomCreatePayload, host_origin: &str) -> Result<()> {
1549 if payload.host == host_origin {
1550 Ok(())
1551 } else {
1552 Err(SdkError::protocol(
1553 "host_mismatch",
1554 format!("room.create names {}, not {host_origin}", payload.host),
1555 ))
1556 }
1557}
1558
1559pub fn validate_message_create_payload(payload: &MessageCreatePayload) -> Result<()> {
1560 if payload.content_type.trim().is_empty() {
1561 return Err(invalid_event("content_type must not be empty"));
1562 }
1563 if !payload.content.is_string() && !payload.content.is_object() {
1564 return Err(invalid_event("content must be a string or an object"));
1565 }
1566 Ok(())
1567}
1568
1569pub fn validate_join_request_envelope(
1573 envelope: &Envelope<RoomJoinRequestPayload>,
1574 room_id: Option<&str>,
1575) -> Result<()> {
1576 validate_discourse_envelope(envelope)?;
1577 if envelope.event.kind != event_type::ROOM_JOIN_REQUEST {
1578 return Err(invalid_event(
1579 "embedded request must be a room.join.request",
1580 ));
1581 }
1582 if let Some(room_id) = room_id {
1583 if envelope.event.room_id.as_deref() != Some(room_id) {
1584 return Err(SdkError::RoomIdMismatch {
1585 expected: room_id.to_owned(),
1586 actual: envelope.event.room_id.clone().unwrap_or_default(),
1587 });
1588 }
1589 }
1590 Ok(())
1591}
1592
1593pub fn validate_room_join_review_payload(
1595 payload: &RoomJoinReviewPayload,
1596 room_id: Option<&str>,
1597) -> Result<()> {
1598 validate_join_request_envelope(&payload.request, room_id)?;
1599 if payload.decision == JoinDecision::Approve && payload.role.is_none() {
1600 return Err(invalid_event("an approving review requires a role"));
1601 }
1602 Ok(())
1603}
1604
1605pub fn validate_room_update_payload(payload: &RoomUpdatePayload) -> Result<()> {
1609 if payload.is_empty() {
1610 return Err(invalid_event("room.update payload must not be empty"));
1611 }
1612 if matches!(&payload.topic, Some(topic) if topic.trim().is_empty()) {
1613 return Err(invalid_event("room topic must not be empty"));
1614 }
1615 if let (Some(start_time), Some(end_time)) = (payload.start_time, payload.end_time) {
1616 if start_time >= end_time {
1617 return Err(invalid_event("start_time must be before end_time"));
1618 }
1619 }
1620 validate_room_policy(payload.policy.as_ref())
1621}
1622
1623pub fn validate_room_member_remove_payload(payload: &RoomMemberRemovePayload) -> Result<()> {
1626 payload.member.public_key_bytes()?;
1627 Ok(())
1628}
1629
1630#[derive(Clone, Debug, Default)]
1632pub struct TypeRegistry {
1633 types: BTreeMap<String, TypeDef>,
1634}
1635
1636impl TypeRegistry {
1637 pub fn new() -> Self {
1638 Self::default()
1639 }
1640
1641 pub fn from_declarations(
1645 declarations: &[TypeDeclaration],
1646 packs: &BTreeMap<String, Pack>,
1647 ) -> Result<Self> {
1648 let mut registry = Self::new();
1649 let mut declared = BTreeSet::new();
1650 for declaration in declarations {
1651 for name in registry.apply(declaration, packs)? {
1652 if !declared.insert(name.clone()) {
1653 return Err(SdkError::protocol(
1654 "type_conflict",
1655 format!("type {name} is declared twice"),
1656 ));
1657 }
1658 }
1659 }
1660 Ok(registry)
1661 }
1662
1663 pub fn apply(
1667 &mut self,
1668 declaration: &TypeDeclaration,
1669 packs: &BTreeMap<String, Pack>,
1670 ) -> Result<Vec<String>> {
1671 match declaration {
1672 TypeDeclaration::Def(def) => {
1673 self.define(def.clone())?;
1674 Ok(vec![def.name.clone()])
1675 }
1676 TypeDeclaration::Import(import) => self.import(import, packs),
1677 }
1678 }
1679
1680 pub fn define(&mut self, def: TypeDef) -> Result<()> {
1681 validate_type_def(&def)?;
1682 if let Some(existing) = self.types.get(&def.name) {
1683 if existing.kind != def.kind {
1684 return Err(SdkError::protocol(
1685 "type_conflict",
1686 format!("type {} cannot change kind on redefinition", def.name),
1687 ));
1688 }
1689 }
1690 self.types.insert(def.name.clone(), def);
1691 Ok(())
1692 }
1693
1694 fn import(
1695 &mut self,
1696 import: &PackImport,
1697 packs: &BTreeMap<String, Pack>,
1698 ) -> Result<Vec<String>> {
1699 validate_pack_import(import)?;
1700 let reference = import
1701 .use_pack
1702 .as_deref()
1703 .or(import.pack.as_deref())
1704 .expect("validated pack import has a reference");
1705 let pack = packs
1706 .get(reference)
1707 .ok_or_else(|| SdkError::PackUnavailable(reference.to_owned()))?;
1708 let mut available = BTreeSet::new();
1709 for def in &pack.types {
1710 if !available.insert(def.name.as_str()) {
1711 return Err(SdkError::protocol(
1712 "type_conflict",
1713 format!("pack {reference} defines {} twice", def.name),
1714 ));
1715 }
1716 }
1717 if let Some(subset) = &import.types {
1718 for name in subset {
1719 if !available.contains(name.as_str()) {
1720 return Err(SdkError::protocol(
1721 "type_conflict",
1722 format!("type {name} is not in pack {reference}"),
1723 ));
1724 }
1725 }
1726 }
1727 for name in import.overrides.iter().flat_map(BTreeMap::keys) {
1728 let imported = import
1729 .types
1730 .as_ref()
1731 .map(|subset| subset.contains(name))
1732 .unwrap_or_else(|| available.contains(name.as_str()));
1733 if !imported {
1734 return Err(SdkError::protocol(
1735 "type_conflict",
1736 format!("override target {name} is not imported from pack {reference}"),
1737 ));
1738 }
1739 }
1740 let mut declared = Vec::new();
1741 for def in &pack.types {
1742 if let Some(subset) = &import.types {
1743 if !subset.contains(&def.name) {
1744 continue;
1745 }
1746 }
1747 let mut def = def.clone();
1748 if let Some(over) = import
1749 .overrides
1750 .as_ref()
1751 .and_then(|overrides| overrides.get(&def.name))
1752 {
1753 if let Some(roles) = &over.roles {
1754 def.roles = Some(roles.clone());
1755 }
1756 if let Some(instructions) = &over.instructions {
1757 def.instructions = Some(instructions.clone());
1758 }
1759 if let Some(status) = over.status {
1760 def.status = Some(status);
1761 }
1762 if let Some(rate_hint) = over.rate_hint {
1763 def.rate_hint = Some(rate_hint);
1764 }
1765 if let Some(max_payload_hint) = over.max_payload_hint {
1766 def.max_payload_hint = Some(max_payload_hint);
1767 }
1768 }
1769 declared.push(def.name.clone());
1770 self.define(def)?;
1771 }
1772 Ok(declared)
1773 }
1774
1775 pub fn get(&self, event_type: &str) -> Option<&TypeDef> {
1776 self.types.get(event_type)
1777 }
1778
1779 pub fn contains(&self, event_type: &str) -> bool {
1780 self.types.contains_key(event_type)
1781 }
1782
1783 pub fn len(&self) -> usize {
1784 self.types.len()
1785 }
1786
1787 pub fn is_empty(&self) -> bool {
1788 self.types.is_empty()
1789 }
1790
1791 pub fn definitions(&self) -> impl Iterator<Item = &TypeDef> {
1792 self.types.values()
1793 }
1794
1795 pub fn validate_payload(&self, event_type: &str, payload: &Value) -> Result<()> {
1797 let def = self
1798 .get(event_type)
1799 .ok_or_else(|| SdkError::TypeNotDefined(event_type.to_owned()))?;
1800 if def.status() == TypeStatus::Disabled {
1801 return Err(SdkError::TypeDisabled(event_type.to_owned()));
1802 }
1803 let validator = compile_schema(&def.schema)?;
1804 if let Err(error) = validator.validate(payload) {
1805 return Err(SdkError::PayloadSchemaViolation(format!(
1806 "{event_type}: {error}"
1807 )));
1808 }
1809 Ok(())
1810 }
1811}
1812
1813pub fn validate_event_against_registry(
1816 event_type: &str,
1817 payload: &Value,
1818 registry: &TypeRegistry,
1819) -> Result<()> {
1820 if is_builtin_event_type(event_type) {
1821 return Ok(());
1822 }
1823 registry.validate_payload(event_type, payload)
1824}
1825
1826fn compile_schema(schema: &Value) -> Result<jsonschema::Validator> {
1827 jsonschema::options()
1829 .with_draft(jsonschema::Draft::Draft202012)
1830 .should_validate_formats(false)
1831 .build(schema)
1832 .map_err(|err| invalid_schema(format!("invalid type schema: {err}")))
1833}
1834
1835pub fn verify_pack_digest(bytes: &[u8], digest: &str) -> Result<()> {
1838 validate_content_digest(digest)
1839 .map_err(|_| SdkError::PackUnavailable(format!("invalid digest format: {digest}")))?;
1840 if URL_SAFE_NO_PAD.encode(Sha3_256::digest(bytes)) == digest {
1841 Ok(())
1842 } else {
1843 Err(SdkError::PackUnavailable("pack digest mismatch".to_owned()))
1844 }
1845}
1846
1847pub fn server_record_hash_payload(
1848 room_id: &str,
1849 seq: u64,
1850 pre_hash: Option<&str>,
1851 envelope_hash: &str,
1852 accepted_at: i64,
1853) -> ServerRecordHashPayload {
1854 ServerRecordHashPayload {
1855 room_id: room_id.to_owned(),
1856 seq,
1857 pre_hash: pre_hash.map(str::to_owned),
1858 envelope_hash: envelope_hash.to_owned(),
1859 accepted_at,
1860 }
1861}
1862
1863pub fn server_record_hash(
1864 room_id: &str,
1865 seq: u64,
1866 pre_hash: Option<&str>,
1867 envelope_hash: &str,
1868 accepted_at: i64,
1869) -> Result<String> {
1870 hash_canonical_json(&server_record_hash_payload(
1871 room_id,
1872 seq,
1873 pre_hash,
1874 envelope_hash,
1875 accepted_at,
1876 ))
1877}
1878
1879pub fn build_server_record<P>(
1880 room_id: impl Into<String>,
1881 seq: u64,
1882 pre_hash: Option<String>,
1883 accepted_at: i64,
1884 envelope: Envelope<P>,
1885) -> Result<ServerRecord<P>> {
1886 let room_id = room_id.into();
1887 let hash = server_record_hash(
1888 &room_id,
1889 seq,
1890 pre_hash.as_deref(),
1891 &envelope.hash,
1892 accepted_at,
1893 )
1894 .expect("server record hash payload is always serializable");
1895 Ok(ServerRecord {
1896 room_id,
1897 seq,
1898 pre_hash,
1899 hash,
1900 accepted_at,
1901 envelope,
1902 })
1903}
1904
1905pub fn redact_server_record(record: &ServerRecord) -> Result<RedactedServerRecord> {
1908 let kind = record.envelope.event.kind.clone();
1909 if is_builtin_event_type(&kind) && kind != event_type::MESSAGE_CREATE {
1910 return Err(invalid_event(format!("{kind} records cannot be redacted")));
1911 }
1912 Ok(RedactedServerRecord {
1913 room_id: record.room_id.clone(),
1914 seq: record.seq,
1915 pre_hash: record.pre_hash.clone(),
1916 hash: record.hash.clone(),
1917 accepted_at: record.accepted_at,
1918 envelope: RedactedEnvelope {
1919 hash: record.envelope.hash.clone(),
1920 redacted: true,
1921 kind,
1922 },
1923 })
1924}
1925
1926pub fn verify_server_record<P>(record: &ServerRecord<P>) -> Result<()>
1927where
1928 P: Serialize,
1929{
1930 verify_record_hash(
1931 &record.room_id,
1932 record.seq,
1933 record.pre_hash.as_deref(),
1934 &record.envelope.hash,
1935 record.accepted_at,
1936 &record.hash,
1937 )
1938}
1939
1940fn verify_record_hash(
1941 room_id: &str,
1942 seq: u64,
1943 pre_hash: Option<&str>,
1944 envelope_hash: &str,
1945 accepted_at: i64,
1946 hash: &str,
1947) -> Result<()> {
1948 let expected = server_record_hash(room_id, seq, pre_hash, envelope_hash, accepted_at)
1949 .expect("server record hash payload is always serializable");
1950 if hash == expected {
1951 Ok(())
1952 } else {
1953 Err(SdkError::InvalidEventHash {
1954 expected,
1955 actual: hash.to_owned(),
1956 })
1957 }
1958}
1959
1960pub fn verify_archive_record(record: &ArchiveRecord) -> Result<()> {
1963 verify_record_hash(
1964 record.room_id(),
1965 record.seq(),
1966 record.pre_hash(),
1967 record.envelope_hash(),
1968 record.accepted_at(),
1969 record.hash(),
1970 )?;
1971 if let ArchiveRecord::Redacted(redacted) = record {
1972 let kind = redacted.envelope.kind.as_str();
1973 if !redacted.envelope.redacted
1974 || (is_builtin_event_type(kind) && kind != event_type::MESSAGE_CREATE)
1975 {
1976 return Err(SdkError::InvalidPayload(format!(
1977 "{kind} records cannot be redacted"
1978 )));
1979 }
1980 }
1981 Ok(())
1982}
1983
1984pub fn verify_server_record_chain<P>(records: &[ServerRecord<P>]) -> Result<()>
1985where
1986 P: Serialize,
1987{
1988 let mut previous: Option<&ServerRecord<P>> = None;
1989 for record in records {
1990 verify_server_record(record)?;
1991 check_link(
1992 record.seq,
1993 record.pre_hash.as_deref(),
1994 previous.map(|p| (p.seq, p.hash.as_str())),
1995 )?;
1996 previous = Some(record);
1997 }
1998 Ok(())
1999}
2000
2001fn check_link(seq: u64, pre_hash: Option<&str>, previous: Option<(u64, &str)>) -> Result<()> {
2002 match previous {
2003 Some((previous_seq, previous_hash)) => {
2004 if seq != previous_seq + 1 {
2005 return Err(SdkError::InvalidPayload(
2006 "seq must increase by 1".to_owned(),
2007 ));
2008 }
2009 if pre_hash != Some(previous_hash) {
2010 return Err(SdkError::InvalidPayload("pre_hash mismatch".to_owned()));
2011 }
2012 }
2013 None if seq != 1 => {
2014 return Err(SdkError::InvalidPayload("first seq must be 1".to_owned()));
2015 }
2016 None if pre_hash.is_some() => {
2017 return Err(SdkError::InvalidPayload(
2018 "first pre_hash must be null".to_owned(),
2019 ));
2020 }
2021 None => {}
2022 }
2023 Ok(())
2024}
2025
2026pub fn verify_archive_chain(records: &[ArchiveRecord]) -> Result<()> {
2028 let mut previous: Option<&ArchiveRecord> = None;
2029 for record in records {
2030 verify_archive_record(record)?;
2031 check_link(
2032 record.seq(),
2033 record.pre_hash(),
2034 previous.map(|p| (p.seq(), p.hash())),
2035 )?;
2036 previous = Some(record);
2037 }
2038 Ok(())
2039}
2040
2041pub fn verify_archive_records(
2046 manifest: &ArchiveManifest,
2047 records: &[ArchiveRecord],
2048) -> Result<Vec<u64>> {
2049 verify_archive_chain(records)?;
2050 match records.last() {
2051 Some(last) if last.seq() == manifest.last_seq && last.hash() == manifest.last_hash => {}
2052 _ => {
2053 return Err(SdkError::InvalidPayload(
2054 "archive does not end at last_seq / last_hash".to_owned(),
2055 ))
2056 }
2057 }
2058 let mut redacted = Vec::new();
2059 for record in records {
2060 if record.room_id() != manifest.room_id {
2061 return Err(SdkError::InvalidPayload(
2062 "record belongs to another room".to_owned(),
2063 ));
2064 }
2065 match record {
2066 ArchiveRecord::Redacted(r) => redacted.push(r.seq),
2067 ArchiveRecord::Signed(r) => validate_discourse_envelope(&r.envelope)?,
2068 }
2069 }
2070 Ok(redacted)
2071}
2072
2073#[derive(Clone, Debug, Default, PartialEq, Eq)]
2075pub struct PermissionContext {
2076 pub role: Option<Role>,
2077 pub is_creator: bool,
2078 pub direct_join_allowed: bool,
2080}
2081
2082impl PermissionContext {
2083 pub fn for_role(role: Role) -> Self {
2084 Self {
2085 role: Some(role),
2086 ..Self::default()
2087 }
2088 }
2089
2090 pub fn creator(role: Option<Role>) -> Self {
2091 Self {
2092 role,
2093 is_creator: true,
2094 ..Self::default()
2095 }
2096 }
2097}
2098
2099pub fn default_kind_roles(kind: TypeKind) -> &'static [Role] {
2101 match kind {
2102 TypeKind::Message => &[Role::Moderator, Role::Speaker],
2103 TypeKind::Signal => &ROLE_ORDER,
2104 TypeKind::Control => &[Role::Moderator],
2105 }
2106}
2107
2108pub fn can_submit_event(
2111 event_type: &str,
2112 context: &PermissionContext,
2113 registry: &TypeRegistry,
2114) -> bool {
2115 match event_type {
2116 event_type::ROOM_CREATE => true,
2117 event_type::ROOM_JOIN => {
2118 context.direct_join_allowed && !context.is_creator && context.role.is_none()
2119 }
2120 event_type::ROOM_JOIN_REQUEST => !context.is_creator && context.role.is_none(),
2121 event_type::ROOM_LEAVE => !context.is_creator && context.role.is_some(),
2123 event_type::ROOM_UPDATE
2124 | event_type::ROOM_JOIN_REVIEW
2125 | event_type::ROOM_MEMBER_ROLE_UPDATE
2126 | event_type::ROOM_MEMBER_REMOVE
2127 | event_type::ROOM_CLOSE
2128 | event_type::ROOM_CANCEL
2129 | event_type::TYPE_DEFINE => context.is_creator || context.role == Some(Role::Moderator),
2130 event_type::MESSAGE_CREATE => {
2131 context.is_creator
2132 || matches!(context.role, Some(Role::Moderator) | Some(Role::Speaker))
2133 }
2134 custom => {
2135 let Some(def) = registry.get(custom) else {
2136 return false;
2137 };
2138 if def.status() == TypeStatus::Disabled {
2139 return false;
2140 }
2141 if context.is_creator {
2142 return true;
2143 }
2144 let Some(role) = context.role else {
2145 return false;
2146 };
2147 match &def.roles {
2148 Some(roles) => roles.contains(&role),
2149 None => default_kind_roles(def.kind).contains(&role),
2150 }
2151 }
2152 }
2153}
2154
2155pub fn can_write_in_state(event_type: &str, state: RoomState) -> bool {
2156 match state {
2157 RoomState::Scheduled => matches!(
2158 event_type,
2159 event_type::ROOM_JOIN_REQUEST
2160 | event_type::ROOM_JOIN
2161 | event_type::ROOM_JOIN_REVIEW
2162 | event_type::ROOM_MEMBER_ROLE_UPDATE
2163 | event_type::ROOM_MEMBER_REMOVE
2164 | event_type::ROOM_LEAVE
2165 | event_type::ROOM_UPDATE
2166 | event_type::TYPE_DEFINE
2167 | event_type::ROOM_CANCEL
2168 ),
2169 RoomState::Active => {
2170 event_type != event_type::ROOM_CREATE && event_type != event_type::ROOM_CANCEL
2171 }
2172 RoomState::Ended | RoomState::Cancelled => false,
2173 }
2174}
2175
2176pub fn can_accept_room_write(
2177 event_type: &str,
2178 state: RoomState,
2179 permission: &PermissionContext,
2180 registry: &TypeRegistry,
2181) -> bool {
2182 can_submit_event(event_type, permission, registry) && can_write_in_state(event_type, state)
2183}
2184
2185pub fn validate_room_write(
2186 event_type: &str,
2187 state: RoomState,
2188 permission: &PermissionContext,
2189 registry: &TypeRegistry,
2190) -> Result<()> {
2191 if can_accept_room_write(event_type, state, permission, registry) {
2192 Ok(())
2193 } else {
2194 Err(SdkError::PermissionDenied)
2195 }
2196}
2197
2198fn hash_canonical_json<T>(value: &T) -> Result<String>
2199where
2200 T: Serialize + ?Sized,
2201{
2202 let bytes = serde_jcs::to_vec(value).map_err(|err| SdkError::CanonicalJson(err.to_string()))?;
2203 let digest = Sha3_256::digest(bytes);
2204 Ok(URL_SAFE_NO_PAD.encode(digest))
2205}
2206
2207#[cfg(test)]
2208mod tests {
2209 use super::*;
2210 use crate::identity::AgentSigner;
2211 use serde_json::json;
2212
2213 const HOST: &str = "https://api.example.com";
2214
2215 fn signer(byte: u8) -> AgentSigner {
2216 AgentSigner::from_seed([byte; 32])
2217 }
2218
2219 fn packs() -> BTreeMap<String, Pack> {
2220 let document: PackDocument = serde_json::from_str(include_str!(
2221 "../../../docs/protocols/agent-discourse/1.0.packs.json"
2222 ))
2223 .unwrap();
2224 pack_map(&document)
2225 }
2226
2227 fn vectors() -> Value {
2228 serde_json::from_str(include_str!(
2229 "../../../docs/protocols/agent-discourse/1.0.vectors.json"
2230 ))
2231 .unwrap()
2232 }
2233
2234 fn finding_def() -> TypeDef {
2235 serde_json::from_value(json!({
2236 "type": "review.finding",
2237 "kind": "message",
2238 "title": "Review finding",
2239 "schema": {
2240 "type": "object",
2241 "required": ["severity", "summary"],
2242 "properties": {
2243 "severity": { "type": "string", "enum": ["low", "medium", "high"] },
2244 "summary": { "type": "string", "minLength": 1 }
2245 },
2246 "additionalProperties": false
2247 }
2248 }))
2249 .unwrap()
2250 }
2251
2252 fn room_payload() -> RoomCreatePayload {
2253 RoomCreatePayload::new(HOST, "Research room", Visibility::Public, 1000, 2000)
2254 }
2255
2256 fn message(
2257 author: &AgentSigner,
2258 room: &str,
2259 base_seq: u64,
2260 base_hash: &str,
2261 nonce: u64,
2262 ) -> Envelope<Value> {
2263 author
2264 .sign_event(discourse_event(
2265 event_type::MESSAGE_CREATE,
2266 author.agent_id(),
2267 100,
2268 nonce,
2269 room,
2270 base_seq,
2271 base_hash,
2272 json!({ "content_type": "text/plain", "content": "hi" }),
2273 ))
2274 .unwrap()
2275 }
2276
2277 #[test]
2278 fn kernel_defines_twelve_builtins_and_freshness_classes() {
2279 assert_eq!(BUILTIN_EVENT_TYPES.len(), 12);
2280 assert!(is_builtin_event_type(event_type::ROOM_JOIN_REQUEST));
2281 for kind in MEMBERSHIP_EVENT_TYPES {
2282 assert_eq!(builtin_event_class(kind), Some(RecordClass::Signal));
2283 assert!(!event_type_advances_head(kind, &[]));
2284 assert!(!event_requires_room_head(kind, &[]));
2285 }
2286 for kind in CONTRACT_EVENT_TYPES {
2287 assert_eq!(builtin_event_class(kind), Some(RecordClass::Contract));
2288 assert!(event_type_advances_head(kind, &[]));
2289 assert!(!event_requires_room_head(kind, &[]));
2290 }
2291 assert_eq!(
2292 builtin_event_class(event_type::ROOM_CREATE),
2293 Some(RecordClass::Genesis)
2294 );
2295 assert_eq!(record_class(event_type::ROOM_JOIN_REQUEST, &[]), None);
2296 assert!(event_requires_room_head(event_type::MESSAGE_CREATE, &[]));
2297 assert!(event_requires_room_head("unknown.custom", &[]));
2298 for code in [
2299 "host_mismatch",
2300 "type_conflict",
2301 "invalid_type_schema",
2302 "join_request_not_pending",
2303 ] {
2304 assert!(DISCOURSE_ERROR_CODES.contains(&code));
2305 }
2306 }
2307
2308 #[test]
2309 fn discourse_vectors_reproduce_chains_redaction_and_heads() {
2310 let vectors = vectors();
2311 let records: Vec<ServerRecord> =
2312 serde_json::from_value(vectors["records"].clone()).unwrap();
2313 for record in &records {
2314 verify_server_record(record).unwrap();
2315 validate_discourse_envelope(&record.envelope).unwrap();
2316 }
2317 verify_server_record_chain(&records).unwrap();
2318 let redacted: Vec<ArchiveRecord> =
2319 serde_json::from_value(vectors["redacted_records"].clone()).unwrap();
2320 assert!(redacted[2].is_redacted());
2321 verify_archive_chain(&redacted).unwrap();
2322 let manifest = ArchiveManifest {
2323 protocol: PROTOCOL.into(),
2324 kind: "room.archive".into(),
2325 room_id: vectors["room_id"].as_str().unwrap().into(),
2326 url: format!("{HOST}/v1/rooms/x"),
2327 generated_at: 0,
2328 last_seq: vectors["last_seq"].as_u64().unwrap(),
2329 last_hash: vectors["last_hash"].as_str().unwrap().into(),
2330 formats: BTreeMap::new(),
2331 packs: Vec::new(),
2332 extra: BTreeMap::new(),
2333 };
2334 let signed: Vec<ArchiveRecord> =
2335 records.iter().cloned().map(ArchiveRecord::Signed).collect();
2336 assert_eq!(
2337 verify_archive_records(&manifest, &signed).unwrap(),
2338 Vec::<u64>::new()
2339 );
2340 assert_eq!(
2341 verify_archive_records(&manifest, &redacted).unwrap(),
2342 vec![3]
2343 );
2344 let mut short = manifest.clone();
2345 short.last_hash = records[0].hash.clone();
2346 assert!(verify_archive_records(&short, &signed).is_err());
2347
2348 let create: RoomCreatePayload =
2349 serde_json::from_value(records[0].envelope.event.payload.clone()).unwrap();
2350 let registry =
2351 TypeRegistry::from_declarations(create.types.as_deref().unwrap(), &packs()).unwrap();
2352 let mut head = 0;
2353 for (record, expected) in records
2354 .iter()
2355 .zip(vectors["head_seq_after"].as_array().unwrap())
2356 {
2357 if event_advances_room_head(&record.envelope.event.kind, ®istry) {
2358 head = record.seq;
2359 }
2360 assert_eq!(head, expected.as_u64().unwrap());
2361 }
2362 }
2363
2364 #[test]
2365 fn discourse_vectors_classes_and_patterns() {
2366 let vectors = vectors();
2367 let declarations: Vec<TypeDeclaration> =
2368 serde_json::from_value(vectors["freshness"]["registry"].clone()).unwrap();
2369 let registry = TypeRegistry::from_declarations(&declarations, &packs()).unwrap();
2370 let types: Vec<TypeDef> = registry.definitions().cloned().collect();
2371 let list = |name: &str| -> Vec<String> {
2372 serde_json::from_value(vectors["freshness"][name].clone()).unwrap()
2373 };
2374 let (head_checked, head_advancing) = (list("head_checked"), list("head_advancing"));
2375 for (kind, class) in vectors["freshness"]["classes"].as_object().unwrap() {
2376 let expected: RecordClass = serde_json::from_value(class.clone()).unwrap();
2377 assert_eq!(record_class(kind, &types), Some(expected), "{kind}");
2378 assert_eq!(
2379 event_requires_room_head(kind, &types),
2380 head_checked.contains(kind),
2381 "{kind}"
2382 );
2383 assert_eq!(
2384 event_type_advances_head(kind, &types),
2385 head_advancing.contains(kind),
2386 "{kind}"
2387 );
2388 }
2389 for case in vectors["freshness"]["base_checks"].as_array().unwrap() {
2390 let base_hash = "base-hash";
2391 let anchor = case["anchored"].as_bool().unwrap().then_some(base_hash);
2392 let result = validate_room_base(
2393 case["type"].as_str().unwrap(),
2394 &types,
2395 case["base_seq"].as_u64().unwrap(),
2396 base_hash,
2397 anchor,
2398 case["head_seq"].as_u64().unwrap(),
2399 );
2400 let outcome = match &result {
2401 Ok(()) => "ok",
2402 Err(error) => error.code().unwrap_or("other"),
2403 };
2404 assert_eq!(
2405 outcome,
2406 case["expected"].as_str().unwrap(),
2407 "{}",
2408 case["name"]
2409 );
2410 }
2411 for name in vectors["type_names"]["valid"].as_array().unwrap() {
2412 validate_custom_event_type_name(name.as_str().unwrap()).unwrap();
2413 }
2414 for name in vectors["type_names"]["invalid"].as_array().unwrap() {
2415 let name = name.as_str().unwrap();
2416 assert!(validate_custom_event_type_name(name).is_err(), "{name}");
2417 }
2418 for case in vectors["open_roles"]["effective"].as_array().unwrap() {
2419 let visibility: Visibility =
2420 serde_json::from_value(case["visibility"].clone()).unwrap();
2421 let policy: Option<RoomPolicy> = case
2422 .get("policy")
2423 .map(|p| serde_json::from_value(p.clone()).unwrap());
2424 let expected: Vec<Role> = serde_json::from_value(case["expected"].clone()).unwrap();
2425 assert_eq!(
2426 effective_open_roles(visibility, policy.as_ref()),
2427 expected,
2428 "{}",
2429 case["name"]
2430 );
2431 }
2432 for case in vectors["open_roles"]["invalid"].as_array().unwrap() {
2433 let visibility: Visibility =
2434 serde_json::from_value(case["visibility"].clone()).unwrap();
2435 let policy: RoomPolicy = serde_json::from_value(case["policy"].clone()).unwrap();
2436 let result = validate_room_policy(Some(&policy))
2437 .and_then(|()| validate_room_visibility_policy(visibility, Some(&policy)));
2438 assert!(result.is_err(), "{}", case["name"]);
2439 }
2440 for pattern in vectors["patterns"]["valid"].as_array().unwrap() {
2441 validate_portable_pattern(pattern.as_str().unwrap()).unwrap();
2442 }
2443 for pattern in vectors["patterns"]["invalid"].as_array().unwrap() {
2444 let pattern = pattern.as_str().unwrap();
2445 assert!(validate_portable_pattern(pattern).is_err(), "{pattern}");
2446 }
2447 }
2448
2449 #[test]
2450 fn registered_packs_follow_the_profile() {
2451 let packs = packs();
2452 assert_eq!(packs.len(), 5);
2453 for pack in packs.values() {
2454 for def in &pack.types {
2455 validate_type_def(def).unwrap();
2456 }
2457 }
2458 assert!(packs[pack_id::MODERATION]
2459 .types
2460 .iter()
2461 .any(|d| d.name == "claim.update"));
2462 assert!(!packs[pack_id::REALTIME]
2463 .types
2464 .iter()
2465 .any(|d| d.name == "session.candidate"));
2466 }
2467
2468 #[test]
2469 fn room_create_is_host_bound_and_closed() {
2470 let creator = signer(14);
2471 let envelope = creator
2472 .sign_event(room_create_event(
2473 creator.agent_id(),
2474 100,
2475 1,
2476 room_payload(),
2477 ))
2478 .unwrap();
2479 validate_discourse_envelope(&envelope).unwrap();
2480 validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").unwrap();
2481 validate_room_create_host(&envelope.event.payload, HOST).unwrap();
2482 assert_eq!(
2483 validate_room_create_host(&envelope.event.payload, "https://other.example")
2484 .unwrap_err()
2485 .code(),
2486 Some("host_mismatch")
2487 );
2488 let mut path_host = room_payload();
2489 path_host.host = format!("{HOST}/v1");
2490 assert!(validate_room_create_payload(&path_host).is_err());
2491 let with_room = creator
2492 .sign_event(
2493 room_create_event(creator.agent_id(), 100, 2, room_payload()).with_room_id("r1"),
2494 )
2495 .unwrap();
2496 assert!(validate_discourse_envelope(&with_room).is_err());
2497 let mut extra = room_create_event(creator.agent_id(), 100, 3, room_payload());
2498 extra.extra.insert("audience".into(), json!("x"));
2499 let extra = creator.sign_event(extra).unwrap();
2500 assert_eq!(
2501 validate_discourse_envelope(&extra).unwrap_err().code(),
2502 Some("invalid_event")
2503 );
2504 }
2505
2506 #[test]
2507 fn room_events_require_valid_room_ids_and_bases() {
2508 let author = signer(15);
2509 let no_room = author
2510 .sign_event(Event::new(
2511 PROTOCOL,
2512 event_type::MESSAGE_CREATE,
2513 author.agent_id(),
2514 1,
2515 1,
2516 json!({}),
2517 ))
2518 .unwrap();
2519 assert!(matches!(
2520 validate_discourse_envelope(&no_room),
2521 Err(SdkError::MissingRoomId)
2522 ));
2523 assert!(validate_discourse_envelope(&message(&author, "room/../x", 1, "h", 2)).is_err());
2524 validate_room_path(&message(&author, "room1", 1, "h", 3), "room1").unwrap();
2525 assert!(matches!(
2526 validate_room_path(&message(&author, "room1", 1, "h", 4), "room2"),
2527 Err(SdkError::RoomIdMismatch { .. })
2528 ));
2529 let mentions: Vec<AgentId> = (0..33).map(|i| signer(100 + i).agent_id()).collect();
2530 let many = author
2531 .sign_event(
2532 discourse_event(
2533 event_type::MESSAGE_CREATE,
2534 author.agent_id(),
2535 1,
2536 5,
2537 "room1",
2538 1,
2539 "h",
2540 json!({}),
2541 )
2542 .with_mentions(mentions.clone()),
2543 )
2544 .unwrap();
2545 assert!(validate_discourse_envelope(&many).is_err());
2546 let ok = author
2547 .sign_event(
2548 discourse_event(
2549 event_type::MESSAGE_CREATE,
2550 author.agent_id(),
2551 1,
2552 6,
2553 "room1",
2554 1,
2555 "h",
2556 json!({}),
2557 )
2558 .with_mentions(mentions[..32].to_vec()),
2559 )
2560 .unwrap();
2561 validate_discourse_envelope(&ok).unwrap();
2562 }
2563
2564 #[test]
2565 fn join_requests_are_signed_unanchored_and_embedded_by_reviews() {
2566 let moderator = signer(21);
2567 let applicant = signer(22);
2568 let room = "d8ftedhpqhsusbg001tg";
2569 let mut payload = RoomJoinRequestPayload::new(Role::Speaker);
2570 payload.perspective = Some("reviewer".into());
2571 let request = applicant
2572 .sign_event(room_join_request_event(
2573 applicant.agent_id(),
2574 1,
2575 1,
2576 room,
2577 payload,
2578 ))
2579 .unwrap();
2580 validate_discourse_envelope(&request).unwrap();
2581 let anchored = applicant
2582 .sign_event(
2583 room_join_request_event(
2584 applicant.agent_id(),
2585 1,
2586 2,
2587 room,
2588 RoomJoinRequestPayload::new(Role::Speaker),
2589 )
2590 .with_room_head(1, "h"),
2591 )
2592 .unwrap();
2593 assert!(validate_discourse_envelope(&anchored).is_err());
2594
2595 let review = RoomJoinReviewPayload {
2596 request: request.clone(),
2597 decision: JoinDecision::Approve,
2598 role: Some(Role::Speaker),
2599 reason: None,
2600 extra: None,
2601 };
2602 let signed = moderator
2603 .sign_event(discourse_event(
2604 event_type::ROOM_JOIN_REVIEW,
2605 moderator.agent_id(),
2606 2,
2607 1,
2608 room,
2609 1,
2610 "h",
2611 review.clone(),
2612 ))
2613 .unwrap();
2614 validate_discourse_envelope(&signed).unwrap();
2615 validate_room_join_review_payload(&review, Some(room)).unwrap();
2616 assert!(validate_room_join_review_payload(
2617 &RoomJoinReviewPayload {
2618 role: None,
2619 ..review.clone()
2620 },
2621 Some(room)
2622 )
2623 .is_err());
2624 assert!(validate_room_join_review_payload(&review, Some("other")).is_err());
2625 let mut tampered = review;
2626 tampered.request.event.payload.role = Role::Moderator;
2627 assert!(validate_room_join_review_payload(&tampered, Some(room)).is_err());
2628 }
2629
2630 #[test]
2631 fn direct_join_follows_invites_and_open_roles() {
2632 let invited = signer(23).agent_id();
2633 let stranger = signer(24).agent_id();
2634 let policy = RoomPolicy {
2635 invites: Some(BTreeMap::from([(invited.clone(), Role::Moderator)])),
2636 open_roles: Some(vec![Role::Observer]),
2637 ..RoomPolicy::default()
2638 };
2639 assert!(can_join_directly(
2640 Visibility::Private,
2641 Some(&policy),
2642 &invited,
2643 Role::Moderator
2644 ));
2645 assert!(!can_join_directly(
2646 Visibility::Private,
2647 Some(&policy),
2648 &invited,
2649 Role::Speaker
2650 ));
2651 assert!(can_join_directly(
2653 Visibility::Restricted,
2654 Some(&policy),
2655 &stranger,
2656 Role::Observer
2657 ));
2658 assert!(validate_room_visibility_policy(Visibility::Private, Some(&policy)).is_err());
2659 validate_room_visibility_policy(Visibility::Restricted, Some(&policy)).unwrap();
2660 assert!(can_join_directly(
2661 Visibility::Public,
2662 Some(&policy),
2663 &stranger,
2664 Role::Observer
2665 ));
2666 assert!(!can_join_directly(
2667 Visibility::Public,
2668 Some(&policy),
2669 &stranger,
2670 Role::Speaker
2671 ));
2672 assert_eq!(
2673 effective_open_roles(Visibility::Public, None),
2674 vec![Role::Observer]
2675 );
2676 assert!(effective_open_roles(Visibility::Restricted, None).is_empty());
2677 assert!(!can_join_directly(
2678 Visibility::Private,
2679 None,
2680 &stranger,
2681 Role::Observer
2682 ));
2683 let bad = RoomPolicy {
2684 open_roles: Some(vec![Role::Moderator]),
2685 ..RoomPolicy::default()
2686 };
2687 assert!(validate_room_policy(Some(&bad)).is_err());
2688 assert!(
2689 serde_json::from_value::<RoomPolicy>(json!({ "moderator_agent_ids": [] })).is_err()
2690 );
2691 }
2692
2693 #[test]
2694 fn type_schemas_follow_the_portable_profile() {
2695 validate_type_schema_profile(&json!({
2696 "type": "object",
2697 "properties": { "a": { "$ref": "#/$defs/x" } },
2698 "$defs": { "x": { "type": "string", "pattern": "^a$" } }
2699 }))
2700 .unwrap();
2701 for schema in [
2702 json!({ "$ref": "https://example.com/schema.json" }),
2703 json!({ "$dynamicRef": "#x" }),
2704 json!({ "$schema": "http://json-schema.org/draft-07/schema#" }),
2705 json!({ "properties": { "a": { "pattern": "\\w" } } }),
2706 json!({ "patternProperties": { "\\d": {} } }),
2707 json!({ "allOf": [{ "items": { "pattern": "." } }] }),
2708 ] {
2709 assert_eq!(
2710 validate_type_schema_profile(&schema).unwrap_err().code(),
2711 Some("invalid_type_schema")
2712 );
2713 }
2714 let mut def = finding_def();
2715 def.schema = json!({ "type": "string", "pattern": "\\s" });
2716 assert!(TypeRegistry::new().define(def).is_err());
2717 let registry = TypeRegistry::from_declarations(
2719 &[TypeDeclaration::Import(PackImport {
2720 use_pack: Some(pack_id::CURATION.into()),
2721 ..PackImport::default()
2722 })],
2723 &packs(),
2724 )
2725 .unwrap();
2726 registry
2727 .validate_payload(
2728 "resource.add",
2729 &json!({ "resource_type": "web", "uri": "not a uri" }),
2730 )
2731 .unwrap();
2732 }
2733
2734 #[test]
2735 fn registry_imports_packs_and_rejects_conflicts() {
2736 let packs = packs();
2737 let import = |id: &str| {
2738 TypeDeclaration::Import(PackImport {
2739 use_pack: Some(id.into()),
2740 ..PackImport::default()
2741 })
2742 };
2743 let mut deliberation = PackImport {
2744 use_pack: Some(pack_id::DELIBERATION.into()),
2745 ..PackImport::default()
2746 };
2747 deliberation.overrides = Some(BTreeMap::from([(
2748 "poll.vote".into(),
2749 TypeOverride {
2750 roles: Some(vec![Role::Moderator, Role::Speaker, Role::Observer]),
2751 ..TypeOverride::default()
2752 },
2753 )]));
2754 let registry = TypeRegistry::from_declarations(
2755 &[
2756 import(pack_id::REACTIONS),
2757 TypeDeclaration::Import(deliberation),
2758 TypeDeclaration::Def(finding_def()),
2759 ],
2760 &packs,
2761 )
2762 .unwrap();
2763 assert_eq!(registry.len(), 6);
2764 assert!(can_submit_event(
2765 "poll.vote",
2766 &PermissionContext::for_role(Role::Observer),
2767 ®istry
2768 ));
2769 let twice = TypeRegistry::from_declarations(
2770 &[
2771 TypeDeclaration::Def(finding_def()),
2772 TypeDeclaration::Def(finding_def()),
2773 ],
2774 &packs,
2775 );
2776 assert_eq!(twice.unwrap_err().code(), Some("type_conflict"));
2777 assert!(TypeRegistry::from_declarations(
2778 &[import(pack_id::REACTIONS), import(pack_id::REACTIONS)],
2779 &packs
2780 )
2781 .is_err());
2782 assert!(TypeRegistry::from_declarations(&[import("adp:unknown/1.0")], &packs).is_err());
2783 let mut subset = PackImport {
2784 use_pack: Some(pack_id::DELIBERATION.into()),
2785 types: Some(vec!["poll.vote".into(), "poll.vote".into()]),
2786 ..PackImport::default()
2787 };
2788 assert!(validate_pack_import(&subset).is_err());
2789 subset.types = Some(vec!["does.not.exist".into()]);
2790 assert!(
2791 TypeRegistry::from_declarations(&[TypeDeclaration::Import(subset)], &packs).is_err()
2792 );
2793 let mut registry = TypeRegistry::new();
2794 registry.define(finding_def()).unwrap();
2795 let mut signal = finding_def();
2796 signal.kind = TypeKind::Signal;
2797 assert_eq!(
2798 registry.define(signal).unwrap_err().code(),
2799 Some("type_conflict")
2800 );
2801 let external = PackImport {
2802 pack: Some("http://example.com/p.json".into()),
2803 digest: Some("A".repeat(43)),
2804 ..PackImport::default()
2805 };
2806 assert!(validate_pack_import(&external).is_err());
2807 let digest = PackImport {
2808 pack: Some("https://example.com/p.json".into()),
2809 digest: Some("sha256:abc".into()),
2810 ..PackImport::default()
2811 };
2812 assert!(validate_pack_import(&digest).is_err());
2813 }
2814
2815 #[test]
2816 fn verifies_pack_digests() {
2817 let bytes = b"pack document bytes";
2818 let sha3 = URL_SAFE_NO_PAD.encode(Sha3_256::digest(bytes));
2819 verify_pack_digest(bytes, &sha3).unwrap();
2820 assert!(verify_pack_digest(b"tampered", &sha3).is_err());
2821 assert!(verify_pack_digest(bytes, &format!("sha3-256:{sha3}")).is_err());
2822 assert!(verify_pack_digest(bytes, "md5:abc").is_err());
2823 }
2824
2825 #[test]
2826 fn permissions_follow_kinds_and_builtin_rules() {
2827 let registry = TypeRegistry::from_declarations(
2828 &[
2829 TypeDeclaration::Import(PackImport {
2830 use_pack: Some(pack_id::REACTIONS.into()),
2831 ..PackImport::default()
2832 }),
2833 TypeDeclaration::Import(PackImport {
2834 use_pack: Some(pack_id::CURATION.into()),
2835 ..PackImport::default()
2836 }),
2837 ],
2838 &packs(),
2839 )
2840 .unwrap();
2841 let observer = PermissionContext::for_role(Role::Observer);
2842 let speaker = PermissionContext::for_role(Role::Speaker);
2843 let moderator = PermissionContext::for_role(Role::Moderator);
2844 let creator = PermissionContext::creator(Some(Role::Observer));
2845 assert!(can_submit_event("reaction.create", &observer, ®istry));
2846 assert!(can_submit_event("resource.add", &speaker, ®istry));
2847 assert!(!can_submit_event("resource.add", &observer, ®istry));
2848 assert!(can_submit_event("graph.update", &moderator, ®istry));
2849 assert!(!can_submit_event("graph.update", &speaker, ®istry));
2850 assert!(can_submit_event("graph.update", &creator, ®istry));
2851 assert!(!can_submit_event("session.offer", &speaker, ®istry));
2852 assert!(can_submit_event(
2853 event_type::ROOM_LEAVE,
2854 &observer,
2855 ®istry
2856 ));
2857 assert!(!can_submit_event(
2858 event_type::ROOM_LEAVE,
2859 &PermissionContext::creator(Some(Role::Moderator)),
2860 ®istry
2861 ));
2862 assert!(!can_submit_event(
2863 event_type::ROOM_JOIN,
2864 &PermissionContext::default(),
2865 ®istry
2866 ));
2867 assert!(can_submit_event(
2868 event_type::ROOM_JOIN,
2869 &PermissionContext {
2870 direct_join_allowed: true,
2871 ..PermissionContext::default()
2872 },
2873 ®istry
2874 ));
2875 assert!(can_submit_event(
2876 event_type::ROOM_JOIN_REQUEST,
2877 &PermissionContext::default(),
2878 ®istry
2879 ));
2880 assert!(!can_submit_event(
2881 event_type::ROOM_JOIN_REQUEST,
2882 &speaker,
2883 ®istry
2884 ));
2885 assert!(can_write_in_state(
2886 event_type::ROOM_JOIN_REQUEST,
2887 RoomState::Scheduled
2888 ));
2889 assert!(!can_write_in_state(
2890 event_type::ROOM_JOIN_REQUEST,
2891 RoomState::Ended
2892 ));
2893 assert!(!can_write_in_state(
2894 event_type::ROOM_CLOSE,
2895 RoomState::Scheduled
2896 ));
2897 assert!(!can_write_in_state(
2898 event_type::ROOM_CANCEL,
2899 RoomState::Active
2900 ));
2901 validate_room_write(
2902 event_type::MESSAGE_CREATE,
2903 RoomState::Active,
2904 &speaker,
2905 ®istry,
2906 )
2907 .unwrap();
2908 assert!(validate_room_write(
2909 event_type::MESSAGE_CREATE,
2910 RoomState::Ended,
2911 &speaker,
2912 ®istry
2913 )
2914 .is_err());
2915 }
2916
2917 #[test]
2918 fn validates_payload_shapes() {
2919 validate_message_create_payload(&MessageCreatePayload::text("hi")).unwrap();
2920 validate_message_create_payload(&MessageCreatePayload::new(
2921 "application/json",
2922 json!({ "a": 1 }),
2923 ))
2924 .unwrap();
2925 for content in [json!(1), json!([]), json!(null)] {
2926 assert!(validate_message_create_payload(&MessageCreatePayload::new(
2927 "application/json",
2928 content
2929 ))
2930 .is_err());
2931 }
2932 assert!(validate_room_update_payload(&RoomUpdatePayload::default()).is_err());
2933 assert!(serde_json::from_value::<RoomUpdatePayload>(json!({ "host": "x" })).is_err());
2934 assert!(
2935 serde_json::from_value::<RoomUpdatePayload>(json!({ "visibility": "private" }))
2936 .is_err()
2937 );
2938 let update = RoomUpdatePayload {
2939 start_time: Some(5),
2940 end_time: Some(5),
2941 ..RoomUpdatePayload::default()
2942 };
2943 assert!(validate_room_update_payload(&update).is_err());
2944 assert!(serde_json::from_value::<RoomJoinPayload>(
2945 json!({ "role": "speaker", "request_id": "jr" })
2946 )
2947 .is_err());
2948 let member = signer(41).agent_id();
2949 validate_room_member_remove_payload(&RoomMemberRemovePayload::new(member)).unwrap();
2950 let mut bad_create = room_payload();
2951 bad_create.topic = " ".into();
2952 assert!(validate_room_create_payload(&bad_create).is_err());
2953 }
2954
2955 #[test]
2956 fn builds_redacts_and_verifies_record_chains() {
2957 let author = signer(18);
2958 let create = author
2959 .sign_event(room_create_event(author.agent_id(), 100, 1, room_payload()))
2960 .unwrap();
2961 let first = build_server_record("room123", 1, None, 110, create).unwrap();
2962 let second = build_server_record(
2963 "room123",
2964 2,
2965 Some(first.hash.clone()),
2966 130,
2967 message(&author, "room123", 1, &first.hash, 2),
2968 )
2969 .unwrap();
2970 let first_value: ServerRecord =
2971 serde_json::from_value(serde_json::to_value(&first).unwrap()).unwrap();
2972 verify_server_record_chain(&[first_value.clone(), second.clone()]).unwrap();
2973 assert!(verify_server_record_chain(std::slice::from_ref(&second)).is_err());
2974 let redacted = redact_server_record(&second).unwrap();
2975 assert_eq!(redacted.envelope.kind, event_type::MESSAGE_CREATE);
2976 verify_archive_chain(&[
2977 ArchiveRecord::Signed(first_value.clone()),
2978 ArchiveRecord::Redacted(redacted),
2979 ])
2980 .unwrap();
2981 assert!(redact_server_record(&first_value).is_err());
2982 }
2983}