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