1use serde_json::{json, Map, Value};
28use time::format_description::well_known::Rfc3339;
29use time::OffsetDateTime;
30
31use didbot_schema::InvalidRecord;
32
33pub const POLICY_COLLECTION: &str = didbot_lexicon::nsid::POLICY;
35
36pub const BINDING_COLLECTION: &str = didbot_lexicon::nsid::POLICY_BINDING;
38
39pub const REGEX_DOCUMENT: &str = "bot.did.policy#regex";
41
42pub const CEDAR_DOCUMENT: &str = "bot.did.policy#cedar";
44
45pub const COUNT_DOCUMENT: &str = "bot.did.policy#count";
47
48const POLICY_FIELDS: &[&str] = &[
50 "name",
51 "description",
52 "actions",
53 "document",
54 "lookups",
55 "createdAt",
56];
57
58const LOOKUP_FIELDS: &[&str] = &["name", "accounts", "collection", "rkey"];
60
61const NAMED: &str = "named";
63
64const SUBJECTS: &str = "subjects";
66
67const DESCENDANTS: &str = "descendants";
69
70const BINDING_FIELDS: &[&str] = &["policies", "subjects", "includes", "excludes", "createdAt"];
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct PolicyRecord {
76 pub name: Option<String>,
79 pub description: Option<String>,
81 pub actions: Vec<Action>,
83 pub document: Document,
85 pub lookups: Vec<Lookup>,
88 pub created_at: OffsetDateTime,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Hash)]
95pub enum Action {
96 RecordWrite,
98 BlobWrite,
100 OauthAuthorize,
102 OauthToken,
104 AccountCreate,
106 Other(String),
108}
109
110impl Action {
111 #[must_use]
113 pub fn parse(name: &str) -> Self {
114 match name {
115 "record.write" => Self::RecordWrite,
116 "blob.write" => Self::BlobWrite,
117 "oauth.authorize" => Self::OauthAuthorize,
118 "oauth.token" => Self::OauthToken,
119 "account.create" => Self::AccountCreate,
120 other => Self::Other(other.to_owned()),
121 }
122 }
123
124 #[must_use]
126 pub fn as_str(&self) -> &str {
127 match self {
128 Self::RecordWrite => "record.write",
129 Self::BlobWrite => "blob.write",
130 Self::OauthAuthorize => "oauth.authorize",
131 Self::OauthToken => "oauth.token",
132 Self::AccountCreate => "account.create",
133 Self::Other(other) => other,
134 }
135 }
136}
137
138impl std::fmt::Display for Action {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 f.write_str(self.as_str())
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum Document {
147 Regex {
150 statements: Vec<Value>,
152 },
153 Cedar {
155 source: String,
157 },
158 Count {
161 statements: Vec<Value>,
163 },
164 Other {
167 r#type: String,
169 },
170}
171
172impl Document {
173 #[must_use]
175 pub fn type_name(&self) -> &str {
176 match self {
177 Self::Regex { .. } => REGEX_DOCUMENT,
178 Self::Cedar { .. } => CEDAR_DOCUMENT,
179 Self::Count { .. } => COUNT_DOCUMENT,
180 Self::Other { r#type } => r#type,
181 }
182 }
183
184 fn from_json(value: &Value) -> Result<Self, InvalidRecord> {
187 let object = object(value, "")?;
188 let type_name = string(object, "$type")?;
189 match type_name {
190 REGEX_DOCUMENT => {
191 only_fields(object, &["$type", "statements"], "a `#regex` document")?;
192 let statements = array(object, "statements")?.to_vec();
193 if statements.is_empty() {
194 return Err(InvalidRecord::at("statements", "holds no statement"));
195 }
196 Ok(Self::Regex { statements })
197 }
198 CEDAR_DOCUMENT => {
199 only_fields(object, &["$type", "source"], "a `#cedar` document")?;
200 Ok(Self::Cedar {
201 source: string(object, "source")?.to_owned(),
202 })
203 }
204 COUNT_DOCUMENT => {
205 only_fields(object, &["$type", "statements"], "a `#count` document")?;
206 let statements = array(object, "statements")?.to_vec();
207 if statements.is_empty() {
208 return Err(InvalidRecord::at("statements", "holds no statement"));
209 }
210 Ok(Self::Count { statements })
211 }
212 other => Ok(Self::Other {
213 r#type: other.to_owned(),
214 }),
215 }
216 }
217
218 #[must_use]
220 pub fn to_json(&self) -> Value {
221 match self {
222 Self::Regex { statements } => json!({
223 "$type": REGEX_DOCUMENT,
224 "statements": statements,
225 }),
226 Self::Cedar { source } => json!({
227 "$type": CEDAR_DOCUMENT,
228 "source": source,
229 }),
230 Self::Count { statements } => json!({
231 "$type": COUNT_DOCUMENT,
232 "statements": statements,
233 }),
234 Self::Other { r#type } => json!({ "$type": r#type }),
235 }
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct Lookup {
247 pub name: String,
249 pub accounts: Accounts,
251 pub collection: String,
253 pub rkey: Option<String>,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Hash)]
261pub enum Accounts {
262 Named,
264 Other(String),
266}
267
268impl Accounts {
269 #[must_use]
271 pub fn parse(word: &str) -> Self {
272 match word {
273 NAMED => Self::Named,
274 other => Self::Other(other.to_owned()),
275 }
276 }
277
278 #[must_use]
280 pub fn as_str(&self) -> &str {
281 match self {
282 Self::Named => NAMED,
283 Self::Other(other) => other,
284 }
285 }
286}
287
288pub fn lookups_of(record: &Value) -> Result<Vec<Lookup>, InvalidRecord> {
298 let object = object(record, "")?;
299 match object.get("lookups") {
300 None => Ok(Vec::new()),
301 Some(_) => array(object, "lookups")?
302 .iter()
303 .enumerate()
304 .map(|(at, entry)| {
305 Lookup::from_json(entry).map_err(|error| error.under(&index("lookups", at)))
306 })
307 .collect(),
308 }
309}
310
311impl Lookup {
312 fn from_json(value: &Value) -> Result<Self, InvalidRecord> {
314 let object = object(value, "")?;
315 only_fields(object, LOOKUP_FIELDS, "a `lookups` entry")?;
316 let name = string(object, "name")?;
317 if name.is_empty() {
318 return Err(InvalidRecord::at("name", "is empty"));
319 }
320 let collection = string(object, "collection")?;
321 didbot_lexicon::nsid_syntax::validate(collection)
322 .map_err(|error| InvalidRecord::at("collection", format!("is not an nsid: {error}")))?;
323 Ok(Self {
324 name: name.to_owned(),
325 accounts: Accounts::parse(string(object, "accounts")?),
326 collection: collection.to_owned(),
327 rkey: optional_string(object, "rkey")?.map(str::to_owned),
328 })
329 }
330
331 #[must_use]
333 pub fn to_json(&self) -> Value {
334 let mut value = json!({
335 "name": self.name,
336 "accounts": self.accounts.as_str(),
337 "collection": self.collection,
338 });
339 if let Some(rkey) = &self.rkey {
340 value["rkey"] = Value::String(rkey.clone());
341 }
342 value
343 }
344
345 #[must_use]
348 pub fn uri(&self, did: &str) -> String {
349 match &self.rkey {
350 Some(rkey) => format!("at://{did}/{}/{rkey}", self.collection),
351 None => format!("at://{did}/{}", self.collection),
352 }
353 }
354}
355
356impl PolicyRecord {
357 pub fn from_json(value: &Value) -> Result<Self, InvalidRecord> {
359 let object = object(value, "")?;
360 typed_as(object, POLICY_COLLECTION)?;
361 only_fields(object, POLICY_FIELDS, "a `bot.did.policy` record")?;
362
363 let actions = array(object, "actions")?
364 .iter()
365 .enumerate()
366 .map(|(at, entry)| {
367 let path = index("actions", at);
368 entry
369 .as_str()
370 .map(Action::parse)
371 .ok_or_else(|| expected("a string", entry, &path))
372 })
373 .collect::<Result<Vec<_>, _>>()?;
374 if actions.is_empty() {
375 return Err(InvalidRecord::at("actions", "names no action"));
376 }
377
378 let document = Document::from_json(required(object, "document")?)
379 .map_err(|error| error.under("document"))?;
380
381 let lookups = match object.get("lookups") {
382 None => Vec::new(),
383 Some(_) => array(object, "lookups")?
384 .iter()
385 .enumerate()
386 .map(|(at, entry)| {
387 Lookup::from_json(entry).map_err(|error| error.under(&index("lookups", at)))
388 })
389 .collect::<Result<Vec<_>, _>>()?,
390 };
391 let mut seen = std::collections::HashSet::new();
392 for (at, lookup) in lookups.iter().enumerate() {
393 if !seen.insert(lookup.name.as_str()) {
394 return Err(InvalidRecord::at(
395 &index("lookups", at),
396 format!(
397 "names `{}`, which an earlier entry already names",
398 lookup.name
399 ),
400 ));
401 }
402 }
403
404 Ok(Self {
405 name: optional_string(object, "name")?.map(str::to_owned),
406 description: optional_string(object, "description")?.map(str::to_owned),
407 actions,
408 document,
409 lookups,
410 created_at: datetime(object, "createdAt")?,
411 })
412 }
413
414 #[must_use]
416 pub fn to_json(&self) -> Value {
417 let mut value = json!({
418 "$type": POLICY_COLLECTION,
419 "actions": self.actions.iter().map(Action::as_str).collect::<Vec<_>>(),
420 "document": self.document.to_json(),
421 "createdAt": didbot_schema::format::datetime::render(self.created_at),
422 });
423 if let Some(name) = &self.name {
424 value["name"] = Value::String(name.clone());
425 }
426 if let Some(description) = &self.description {
427 value["description"] = Value::String(description.clone());
428 }
429 if !self.lookups.is_empty() {
430 value["lookups"] = Value::Array(self.lookups.iter().map(Lookup::to_json).collect());
431 }
432 value
433 }
434
435 #[must_use]
438 pub fn display_name<'a>(&'a self, key: &'a str) -> &'a str {
439 self.name.as_deref().unwrap_or(key)
440 }
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, Hash)]
446pub struct PolicyRef {
447 pub repository: String,
449 pub key: String,
451}
452
453impl PolicyRef {
454 #[must_use]
456 pub fn uri(&self) -> String {
457 format!("at://{}/{POLICY_COLLECTION}/{}", self.repository, self.key)
458 }
459}
460
461impl std::fmt::Display for PolicyRef {
462 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463 f.write_str(&self.uri())
464 }
465}
466
467#[derive(Debug, Clone, PartialEq, Eq)]
469pub struct PolicyBinding {
470 pub policies: Vec<PolicyRef>,
472 pub subjects: Vec<String>,
475 pub includes: Includes,
477 pub excludes: Vec<String>,
480 pub created_at: OffsetDateTime,
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq)]
487pub struct Includes {
488 pub subjects: bool,
490 pub descendants: bool,
492}
493
494impl Includes {
495 fn from_json(object: &Map<String, Value>) -> Result<Self, InvalidRecord> {
498 let mut includes = Self {
499 subjects: false,
500 descendants: false,
501 };
502 for (at, entry) in array(object, "includes")?.iter().enumerate() {
503 let path = index("includes", at);
504 match entry.as_str() {
505 Some(SUBJECTS) => includes.subjects = true,
506 Some(DESCENDANTS) => includes.descendants = true,
507 Some(other) => {
508 return Err(InvalidRecord::at(
509 &path,
510 format!("`{other}` is neither `{SUBJECTS}` nor `{DESCENDANTS}`"),
511 ))
512 }
513 None => return Err(expected("a string", entry, &path)),
514 }
515 }
516 if !includes.subjects && !includes.descendants {
517 return Err(InvalidRecord::at("includes", "names nobody"));
518 }
519 Ok(includes)
520 }
521
522 #[must_use]
524 pub fn to_json(self) -> Value {
525 let mut members = Vec::new();
526 if self.subjects {
527 members.push(SUBJECTS);
528 }
529 if self.descendants {
530 members.push(DESCENDANTS);
531 }
532 json!(members)
533 }
534}
535
536impl PolicyBinding {
537 pub fn from_json(value: &Value) -> Result<Self, InvalidRecord> {
539 let object = object(value, "")?;
540 typed_as(object, BINDING_COLLECTION)?;
541 only_fields(object, BINDING_FIELDS, "a `bot.did.policyBinding` record")?;
542
543 let policies = array(object, "policies")?
544 .iter()
545 .enumerate()
546 .map(|(at, entry)| policy_ref(entry, &index("policies", at)))
547 .collect::<Result<Vec<_>, _>>()?;
548 if policies.is_empty() {
549 return Err(InvalidRecord::at("policies", "names no policy"));
550 }
551
552 let subjects = dids(object, "subjects")?;
553 if subjects.is_empty() {
554 return Err(InvalidRecord::at("subjects", "names nobody"));
555 }
556
557 let includes = Includes::from_json(object)?;
558 let excludes = if object.contains_key("excludes") {
559 dids(object, "excludes")?
560 } else {
561 Vec::new()
562 };
563
564 Ok(Self {
565 policies,
566 subjects,
567 includes,
568 excludes,
569 created_at: datetime(object, "createdAt")?,
570 })
571 }
572
573 #[must_use]
575 pub fn to_json(&self) -> Value {
576 let mut value = json!({
577 "$type": BINDING_COLLECTION,
578 "policies": self.policies.iter().map(PolicyRef::uri).collect::<Vec<_>>(),
579 "subjects": self.subjects,
580 "includes": self.includes.to_json(),
581 "createdAt": didbot_schema::format::datetime::render(self.created_at),
582 });
583 if !self.excludes.is_empty() {
584 value["excludes"] = json!(self.excludes);
585 }
586 value
587 }
588}
589
590fn typed_as(object: &Map<String, Value>, collection: &str) -> Result<(), InvalidRecord> {
598 match object.get("$type") {
599 None => Ok(()),
600 Some(Value::String(named)) if named == collection => Ok(()),
601 Some(other) => Err(InvalidRecord::at(
602 "$type",
603 format!("is {other}, not `{collection}`"),
604 )),
605 }
606}
607
608fn only_fields(
610 object: &Map<String, Value>,
611 declared: &[&str],
612 what: &str,
613) -> Result<(), InvalidRecord> {
614 for name in object.keys() {
615 if name != "$type" && !declared.contains(&name.as_str()) {
616 return Err(InvalidRecord::at(
617 name,
618 format!("is not a field {what} declares"),
619 ));
620 }
621 }
622 Ok(())
623}
624
625fn expected(what: &str, found: &Value, path: &str) -> InvalidRecord {
626 InvalidRecord::at(path, format!("expected {what}, found {}", kind_of(found)))
627}
628
629fn object<'a>(value: &'a Value, path: &str) -> Result<&'a Map<String, Value>, InvalidRecord> {
630 value
631 .as_object()
632 .ok_or_else(|| expected("an object", value, path))
633}
634
635fn required<'a>(object: &'a Map<String, Value>, name: &str) -> Result<&'a Value, InvalidRecord> {
636 object
637 .get(name)
638 .ok_or_else(|| InvalidRecord::at(name, "is required and is missing"))
639}
640
641fn string<'a>(object: &'a Map<String, Value>, name: &str) -> Result<&'a str, InvalidRecord> {
642 let value = required(object, name)?;
643 value
644 .as_str()
645 .ok_or_else(|| expected("a string", value, name))
646}
647
648fn optional_string<'a>(
649 object: &'a Map<String, Value>,
650 name: &str,
651) -> Result<Option<&'a str>, InvalidRecord> {
652 match object.get(name) {
653 None => Ok(None),
654 Some(_) => string(object, name).map(Some),
655 }
656}
657
658fn array<'a>(object: &'a Map<String, Value>, name: &str) -> Result<&'a [Value], InvalidRecord> {
659 let value = required(object, name)?;
660 value
661 .as_array()
662 .map(Vec::as_slice)
663 .ok_or_else(|| expected("an array", value, name))
664}
665
666fn dids(object: &Map<String, Value>, name: &str) -> Result<Vec<String>, InvalidRecord> {
668 array(object, name)?
669 .iter()
670 .enumerate()
671 .map(|(at, entry)| did(entry, &index(name, at)))
672 .collect()
673}
674
675fn did(value: &Value, path: &str) -> Result<String, InvalidRecord> {
676 let text = value
677 .as_str()
678 .ok_or_else(|| expected("a string", value, path))?;
679 didbot_identity::validate_did(text)
680 .map_err(|error| InvalidRecord::at(path, format!("is not a did: {error}")))?;
681 Ok(text.to_owned())
682}
683
684fn policy_ref(value: &Value, path: &str) -> Result<PolicyRef, InvalidRecord> {
687 let text = value
688 .as_str()
689 .ok_or_else(|| expected("a string", value, path))?;
690 let uri = didbot_schema::AtUri::parse(text)
691 .map_err(|error| InvalidRecord::at(path, format!("is not an at-uri: {error}")))?;
692 let repository = uri.authority();
693 if didbot_identity::validate_did(repository).is_err() {
694 return Err(InvalidRecord::at(
695 path,
696 format!("names its repository by the handle `{repository}`, not by did"),
697 ));
698 }
699 match uri.collection() {
700 Some(POLICY_COLLECTION) => {}
701 Some(other) => {
702 return Err(InvalidRecord::at(
703 path,
704 format!("names a `{other}` record, not a `{POLICY_COLLECTION}`"),
705 ))
706 }
707 None => {
708 return Err(InvalidRecord::at(
709 path,
710 format!("names a repository, not a `{POLICY_COLLECTION}` record"),
711 ))
712 }
713 }
714 let key = uri.rkey().ok_or_else(|| {
715 InvalidRecord::at(
716 path,
717 format!("names the `{POLICY_COLLECTION}` collection, not a record in it"),
718 )
719 })?;
720 Ok(PolicyRef {
721 repository: repository.to_owned(),
722 key: key.to_owned(),
723 })
724}
725
726fn datetime(object: &Map<String, Value>, name: &str) -> Result<OffsetDateTime, InvalidRecord> {
727 let text = string(object, name)?;
728 OffsetDateTime::parse(text, &Rfc3339)
729 .map_err(|error| InvalidRecord::at(name, format!("is not an RFC 3339 datetime: {error}")))
730}
731
732fn index(path: &str, at: usize) -> String {
733 format!("{path}[{at}]")
734}
735
736fn kind_of(value: &Value) -> &'static str {
737 match value {
738 Value::Null => "null",
739 Value::Bool(_) => "a boolean",
740 Value::Number(_) => "a number",
741 Value::String(_) => "a string",
742 Value::Array(_) => "an array",
743 Value::Object(_) => "an object",
744 }
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750 use didbot_schema::validate_shape;
751
752 const OPERATOR: &str = "did:web:operator.example.com";
754
755 fn policy_uri(key: &str) -> String {
757 format!("at://{OPERATOR}/{POLICY_COLLECTION}/{key}")
758 }
759
760 fn policy() -> Value {
762 json!({
763 "name": "fixed-display-name",
764 "description": "an agent keeps the display name it was given",
765 "actions": ["record.write"],
766 "document": {
767 "$type": REGEX_DOCUMENT,
768 "statements": [{
769 "kind": "denyPathChange",
770 "collection": "^app\\.bsky\\.actor\\.profile$",
771 "path": "^displayName$",
772 "reason": "the display name is fixed",
773 }],
774 },
775 "createdAt": "2026-08-25T10:00:00Z",
776 })
777 }
778
779 fn binding() -> Value {
781 json!({
782 "policies": [policy_uri("fixed-display-name"), policy_uri("3jzfcijpj2z2a")],
783 "subjects": ["did:web:pool.agents.example.com"],
784 "includes": ["descendants"],
785 "excludes": ["did:web:kestrel.agents.example.com"],
786 "createdAt": "2026-08-25T10:00:00Z",
787 })
788 }
789
790 fn accept_policy(value: &Value) -> PolicyRecord {
792 validate_shape(POLICY_COLLECTION, value).expect("satisfies the lexicon");
793 PolicyRecord::from_json(value).expect("parses")
794 }
795
796 fn accept_binding(value: &Value) -> PolicyBinding {
797 validate_shape(BINDING_COLLECTION, value).expect("satisfies the lexicon");
798 PolicyBinding::from_json(value).expect("parses")
799 }
800
801 fn without_type(mut value: Value) -> Value {
802 value.as_object_mut().expect("an object").remove("$type");
803 value
804 }
805
806 #[test]
807 fn a_policy_round_trips_through_its_wire_type() {
808 let parsed = accept_policy(&policy());
809 assert_eq!(parsed.actions, vec![Action::RecordWrite]);
810 assert!(
811 matches!(parsed.document, Document::Regex { ref statements } if statements.len() == 1)
812 );
813 assert_eq!(parsed.display_name("rkey"), "fixed-display-name");
814 let written = parsed.to_json();
815 assert_eq!(written["$type"], POLICY_COLLECTION);
816 assert_eq!(without_type(written.clone()), policy());
817 assert_eq!(accept_policy(&written), parsed);
818 }
819
820 #[test]
821 fn a_cedar_policy_round_trips_and_a_nameless_one_is_called_by_its_key() {
822 let value = json!({
823 "actions": ["record.write", "oauth.authorize"],
824 "document": {
825 "$type": CEDAR_DOCUMENT,
826 "source": "forbid(principal, action, resource);",
827 },
828 "createdAt": "2026-08-25T10:00:00Z",
829 });
830 let parsed = accept_policy(&value);
831 assert_eq!(
832 parsed.document,
833 Document::Cedar {
834 source: "forbid(principal, action, resource);".to_owned()
835 }
836 );
837 assert_eq!(parsed.display_name("3jzfcijpj2z2a"), "3jzfcijpj2z2a");
838 assert_eq!(without_type(parsed.to_json()), value);
839 }
840
841 #[test]
842 fn a_binding_round_trips_through_its_wire_type() {
843 let parsed = accept_binding(&binding());
844 assert_eq!(
845 parsed.policies[0],
846 PolicyRef {
847 repository: OPERATOR.to_owned(),
848 key: "fixed-display-name".to_owned()
849 }
850 );
851 assert_eq!(parsed.policies[0].uri(), policy_uri("fixed-display-name"));
852 assert_eq!(
853 parsed.includes,
854 Includes {
855 subjects: false,
856 descendants: true
857 }
858 );
859 assert_eq!(parsed.excludes.len(), 1);
860 let written = parsed.to_json();
861 assert_eq!(written["$type"], BINDING_COLLECTION);
862 assert_eq!(without_type(written.clone()), binding());
863 assert_eq!(accept_binding(&written), parsed);
864 }
865
866 #[test]
867 fn a_field_neither_record_declares_is_refused() {
868 let mut value = policy();
869 value["engine"] = json!("regex");
870 validate_shape(POLICY_COLLECTION, &value).expect("the lexicon's object is open");
871 let error = PolicyRecord::from_json(&value).expect_err("an undeclared field");
872 assert_eq!(error.path, "engine");
873
874 let mut value = binding();
875 value["audience"] = json!(["did:web:agents.example.com"]);
876 let error = PolicyBinding::from_json(&value).expect_err("an undeclared field");
877 assert_eq!(error.path, "audience");
878 }
879
880 #[test]
881 fn a_type_naming_another_collection_is_refused() {
882 let mut value = policy();
883 value["$type"] = json!(BINDING_COLLECTION);
884 let error = PolicyRecord::from_json(&value).expect_err("the wrong collection");
885 assert_eq!(error.path, "$type");
886 }
887
888 #[test]
889 fn an_action_outside_the_known_values_is_carried() {
890 let mut value = policy();
891 value["actions"] = json!(["record.write", "account.lifecycle"]);
892 let parsed = accept_policy(&value);
893 assert_eq!(
894 parsed.actions[1],
895 Action::Other("account.lifecycle".to_owned())
896 );
897 assert_eq!(parsed.actions[1].as_str(), "account.lifecycle");
898 }
899
900 #[test]
903 fn a_lookup_round_trips_and_says_which_uri_it_reads() {
904 let mut value = policy();
905 value["lookups"] = json!([
906 {
907 "name": "declaredAiPreference",
908 "accounts": "named",
909 "collection": "community.lexicon.preference.ai",
910 },
911 {
912 "name": "replyGate",
913 "accounts": "named",
914 "collection": "app.bsky.feed.threadgate",
915 "rkey": "3lbwaaaaaaa2a",
916 },
917 ]);
918 let parsed = accept_policy(&value);
919 assert_eq!(
920 parsed.lookups[0].uri("did:web:person.example"),
921 "at://did:web:person.example/community.lexicon.preference.ai"
922 );
923 assert_eq!(
924 parsed.lookups[1].uri("did:web:person.example"),
925 "at://did:web:person.example/app.bsky.feed.threadgate/3lbwaaaaaaa2a"
926 );
927 assert_eq!(parsed.lookups[0].accounts, Accounts::Named);
928 assert_eq!(without_type(parsed.to_json()), value);
929 }
930
931 #[test]
934 fn two_lookups_under_one_name_are_refused() {
935 let mut value = policy();
936 let entry = json!({
937 "name": "declaredAiPreference",
938 "accounts": "named",
939 "collection": "community.lexicon.preference.ai",
940 });
941 value["lookups"] = json!([entry, entry]);
942 validate_shape(POLICY_COLLECTION, &value).expect("satisfies the lexicon");
943 let refused = PolicyRecord::from_json(&value).expect_err("two entries share a name");
944 assert_eq!(refused.path, "lookups[1]");
945 }
946
947 #[test]
948 fn a_policy_naming_no_action_is_refused_by_both_checks() {
949 let mut value = policy();
950 value["actions"] = json!([]);
951 assert!(validate_shape(POLICY_COLLECTION, &value).is_err());
952 let error = PolicyRecord::from_json(&value).expect_err("no action");
953 assert_eq!(error.path, "actions");
954
955 value["actions"] = json!([7]);
956 let error = PolicyRecord::from_json(&value).expect_err("not a string");
957 assert_eq!(error.path, "actions[0]");
958 }
959
960 #[test]
961 fn a_document_without_a_type_is_refused_by_both_checks() {
962 let mut value = policy();
963 value["document"] = json!({ "statements": [{}] });
964 assert!(validate_shape(POLICY_COLLECTION, &value).is_err());
965 let error = PolicyRecord::from_json(&value).expect_err("no $type");
966 assert_eq!(error.path, "document.$type");
967
968 value["document"] = json!("forbid(principal, action, resource);");
969 let error = PolicyRecord::from_json(&value).expect_err("not an object");
970 assert_eq!(error.path, "document");
971 }
972
973 #[test]
974 fn a_regex_document_holds_statements_and_nothing_else() {
975 let mut value = policy();
976 value["document"] = json!({ "$type": REGEX_DOCUMENT, "statements": [] });
977 assert!(validate_shape(POLICY_COLLECTION, &value).is_err());
978 let error = PolicyRecord::from_json(&value).expect_err("no statements");
979 assert_eq!(error.path, "document.statements");
980
981 value["document"] = json!({ "$type": REGEX_DOCUMENT });
982 let error = PolicyRecord::from_json(&value).expect_err("statements missing");
983 assert_eq!(error.path, "document.statements");
984
985 value["document"] = json!({ "$type": REGEX_DOCUMENT, "statements": [{}], "source": "x" });
986 let error = PolicyRecord::from_json(&value).expect_err("a cedar field");
987 assert_eq!(error.path, "document.source");
988 }
989
990 #[test]
991 fn a_cedar_document_holds_its_source_and_nothing_else() {
992 let mut value = policy();
993 value["document"] = json!({ "$type": CEDAR_DOCUMENT });
994 assert!(validate_shape(POLICY_COLLECTION, &value).is_err());
995 let error = PolicyRecord::from_json(&value).expect_err("source missing");
996 assert_eq!(error.path, "document.source");
997
998 value["document"] = json!({ "$type": CEDAR_DOCUMENT, "source": "x", "statements": [] });
999 let error = PolicyRecord::from_json(&value).expect_err("a regex field");
1000 assert_eq!(error.path, "document.statements");
1001 }
1002
1003 #[test]
1004 fn a_document_of_an_unknown_type_is_carried_by_its_type() {
1005 let mut value = policy();
1006 value["document"] = json!({ "$type": "bot.did.policy#regex2", "statements": [{}] });
1007 let parsed = accept_policy(&value);
1008 assert_eq!(
1009 parsed.document,
1010 Document::Other {
1011 r#type: "bot.did.policy#regex2".to_owned()
1012 }
1013 );
1014 assert_eq!(parsed.document.type_name(), "bot.did.policy#regex2");
1015 }
1016
1017 #[test]
1021 fn a_policy_is_named_by_the_uri_of_a_policy_record() {
1022 let mut value = binding();
1023 value["policies"] = json!(["fixed-display-name"]);
1024 assert!(validate_shape(BINDING_COLLECTION, &value).is_err());
1025 let error = PolicyBinding::from_json(&value).expect_err("a bare record key");
1026 assert_eq!(error.path, "policies[0]");
1027
1028 for (uri, names) in [
1029 (
1030 format!("at://{OPERATOR}/{BINDING_COLLECTION}/x"),
1031 BINDING_COLLECTION,
1032 ),
1033 (format!("at://{OPERATOR}"), "a repository"),
1034 (
1035 format!("at://{OPERATOR}/{POLICY_COLLECTION}"),
1036 "not a record",
1037 ),
1038 (
1039 format!("at://operator.example.com/{POLICY_COLLECTION}/x"),
1040 "handle",
1041 ),
1042 ] {
1043 value["policies"] = json!([uri]);
1044 validate_shape(BINDING_COLLECTION, &value).expect("a well-formed at-uri");
1045 let error = PolicyBinding::from_json(&value).expect_err(&uri);
1046 assert_eq!(error.path, "policies[0]", "{uri}");
1047 assert!(error.reason.contains(names), "{uri}: {}", error.reason);
1048 }
1049
1050 value["policies"] = json!(["at://did:plc:other/bot.did.policy/x"]);
1051 let parsed = accept_binding(&value);
1052 assert_eq!(parsed.policies[0].repository, "did:plc:other");
1053 assert_eq!(parsed.policies[0].key, "x");
1054
1055 value["policies"] = json!([]);
1056 let error = PolicyBinding::from_json(&value).expect_err("no policy");
1057 assert_eq!(error.path, "policies");
1058 }
1059
1060 #[test]
1061 fn a_subject_or_exclusion_that_is_not_a_did_is_refused_by_both_checks() {
1062 let mut value = binding();
1063 value["subjects"] = json!(["pool.agents.example.com"]);
1064 assert!(validate_shape(BINDING_COLLECTION, &value).is_err());
1065 let error = PolicyBinding::from_json(&value).expect_err("not a did");
1066 assert_eq!(error.path, "subjects[0]");
1067
1068 let mut value = binding();
1069 value["excludes"] = json!(["did:web:ok.example", "kestrel"]);
1070 let error = PolicyBinding::from_json(&value).expect_err("not a did");
1071 assert_eq!(error.path, "excludes[1]");
1072
1073 let mut value = binding();
1074 value["subjects"] = json!([]);
1075 let error = PolicyBinding::from_json(&value).expect_err("nobody");
1076 assert_eq!(error.path, "subjects");
1077 }
1078
1079 #[test]
1080 fn includes_is_a_non_empty_set_of_two_words() {
1081 let mut value = binding();
1082 value["includes"] = json!([]);
1083 assert!(validate_shape(BINDING_COLLECTION, &value).is_err());
1084 let error = PolicyBinding::from_json(&value).expect_err("nobody");
1085 assert_eq!(error.path, "includes");
1086
1087 value["includes"] = json!(["self"]);
1088 validate_shape(BINDING_COLLECTION, &value).expect("the lexicon leaves the words open");
1089 let error = PolicyBinding::from_json(&value).expect_err("an old word");
1090 assert_eq!(error.path, "includes[0]");
1091 assert!(error.reason.contains("`self`"), "{}", error.reason);
1092
1093 value["includes"] = json!(["subjects", "subjects"]);
1094 let parsed = accept_binding(&value);
1095 assert_eq!(
1096 parsed.includes,
1097 Includes {
1098 subjects: true,
1099 descendants: false
1100 }
1101 );
1102 assert_eq!(parsed.includes.to_json(), json!(["subjects"]));
1103
1104 value["includes"] = json!(["descendants", "subjects"]);
1105 let parsed = accept_binding(&value);
1106 assert_eq!(
1107 parsed.includes.to_json(),
1108 json!(["subjects", "descendants"])
1109 );
1110 }
1111
1112 #[test]
1113 fn a_binding_without_exclusions_writes_none() {
1114 let mut value = binding();
1115 value.as_object_mut().expect("an object").remove("excludes");
1116 let parsed = accept_binding(&value);
1117 assert!(parsed.excludes.is_empty());
1118 assert!(parsed.to_json().get("excludes").is_none());
1119 }
1120
1121 #[test]
1122 fn a_missing_required_field_names_itself() {
1123 for (collection, sample, field) in [
1124 (POLICY_COLLECTION, policy(), "actions"),
1125 (POLICY_COLLECTION, policy(), "document"),
1126 (POLICY_COLLECTION, policy(), "createdAt"),
1127 (BINDING_COLLECTION, binding(), "policies"),
1128 (BINDING_COLLECTION, binding(), "subjects"),
1129 (BINDING_COLLECTION, binding(), "includes"),
1130 (BINDING_COLLECTION, binding(), "createdAt"),
1131 ] {
1132 let mut value = sample;
1133 value.as_object_mut().expect("an object").remove(field);
1134 assert!(validate_shape(collection, &value).is_err(), "{field}");
1135 let error = match collection {
1136 POLICY_COLLECTION => PolicyRecord::from_json(&value).map(|_| ()),
1137 _ => PolicyBinding::from_json(&value).map(|_| ()),
1138 }
1139 .expect_err(field);
1140 assert_eq!(error.path, field);
1141 }
1142 }
1143}