1use super::keys::{self, FleetKeyError};
5use super::{
6 Authenticator, CHANNELS_COLLECTION, FLEET_NAMESPACE, FleetActor, FleetRole, GrantScope,
7 IDENTITY_BINDINGS_COLLECTION, PEOPLE_COLLECTION, ROLE_GRANTS_COLLECTION,
8};
9use crate::codec::RecordCodec;
10use gonzalo_core::{RecordKey, RecordKind};
11use serde::{Deserialize, Deserializer, Serialize};
12use std::collections::BTreeSet;
13use std::fmt;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Person {
19 pub display_name: String,
21 pub email: Option<String>,
24}
25impl RecordCodec for Person {}
26impl Person {
27 pub const KIND: RecordKind = RecordKind::Person;
28
29 pub fn key(person_id: &str) -> Result<RecordKey, FleetKeyError> {
31 keys::validate_person_id(person_id)?;
32 Ok(RecordKey::new(
33 FLEET_NAMESPACE,
34 PEOPLE_COLLECTION,
35 person_id,
36 ))
37 }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct VerifiedEmail {
43 pub address: String,
44 pub verified: bool,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub enum BindingOrigin {
50 LinkToken { token_hash: String },
52 Operator(FleetActor),
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct IdentityBinding {
59 #[serde(with = "super::atomic")]
63 pub authenticator: Authenticator,
64 pub subject: String,
66 pub person: String,
68 pub handle: Option<String>,
70 #[serde(with = "super::atomic")]
73 pub email: Option<VerifiedEmail>,
74 pub bound_at: i64,
75 #[serde(with = "super::atomic")]
79 pub bound_by: BindingOrigin,
80}
81impl RecordCodec for IdentityBinding {}
82impl IdentityBinding {
83 pub const KIND: RecordKind = RecordKind::IdentityBinding;
84
85 pub fn key_for(
87 authenticator: &Authenticator,
88 subject: &str,
89 ) -> Result<RecordKey, FleetKeyError> {
90 Ok(RecordKey::new(
91 FLEET_NAMESPACE,
92 IDENTITY_BINDINGS_COLLECTION,
93 keys::binding_id(authenticator, subject)?,
94 ))
95 }
96
97 pub fn key(&self) -> Result<RecordKey, FleetKeyError> {
98 Self::key_for(&self.authenticator, &self.subject)
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct RoleGrant {
106 pub person: String,
107 #[serde(with = "super::atomic")]
110 pub scope: GrantScope,
111 pub role: FleetRole,
112 #[serde(with = "super::atomic")]
116 pub granted_by: FleetActor,
117 pub granted_at: i64,
118}
119impl RecordCodec for RoleGrant {}
120impl RoleGrant {
121 pub const KIND: RecordKind = RecordKind::RoleGrant;
122
123 pub fn key_for(person: &str, scope: &GrantScope) -> Result<RecordKey, FleetKeyError> {
125 Ok(RecordKey::new(
126 FLEET_NAMESPACE,
127 ROLE_GRANTS_COLLECTION,
128 keys::grant_id(person, scope)?,
129 ))
130 }
131
132 pub fn key(&self) -> Result<RecordKey, FleetKeyError> {
133 Self::key_for(&self.person, &self.scope)
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
142#[serde(tag = "kind", rename_all = "snake_case")]
143pub enum Follows {
144 Fleet,
146 Workspaces { names: BTreeSet<String> },
149}
150
151impl Follows {
152 pub fn workspaces<I, S>(names: I) -> Result<Self, EmptyFollowSet>
154 where
155 I: IntoIterator<Item = S>,
156 S: Into<String>,
157 {
158 let names: BTreeSet<String> = names.into_iter().map(Into::into).collect();
159 if names.is_empty() {
160 return Err(EmptyFollowSet);
161 }
162 Ok(Follows::Workspaces { names })
163 }
164}
165
166impl<'de> Deserialize<'de> for Follows {
167 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
168 #[derive(Deserialize)]
171 #[serde(tag = "kind", rename_all = "snake_case")]
172 enum Wire {
173 Fleet,
174 Workspaces { names: BTreeSet<String> },
175 }
176 match Wire::deserialize(deserializer)? {
177 Wire::Fleet => Ok(Follows::Fleet),
178 Wire::Workspaces { names } if names.is_empty() => {
179 Err(serde::de::Error::custom(EmptyFollowSet))
180 }
181 Wire::Workspaces { names } => Ok(Follows::Workspaces { names }),
182 }
183 }
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub struct EmptyFollowSet;
189
190impl fmt::Display for EmptyFollowSet {
191 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192 f.write_str("a channel following workspaces must name at least one")
193 }
194}
195
196impl std::error::Error for EmptyFollowSet {}
197
198#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub enum NotifyPreset {
203 #[default]
205 All,
206 Terminal,
208 Failures,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ChannelConfig {
217 pub provider: String,
219 pub tenant: String,
222 pub channel: String,
224 #[serde(with = "super::atomic")]
227 pub follows: Follows,
228 #[serde(default)]
230 pub notify: NotifyPreset,
231 #[serde(default)]
234 pub ceiling: FleetRole,
235}
236impl RecordCodec for ChannelConfig {}
237impl ChannelConfig {
238 pub const KIND: RecordKind = RecordKind::ChannelConfig;
239
240 pub fn key_for(
242 provider: &str,
243 tenant: &str,
244 channel: &str,
245 ) -> Result<RecordKey, FleetKeyError> {
246 Ok(RecordKey::new(
247 FLEET_NAMESPACE,
248 CHANNELS_COLLECTION,
249 keys::channel_id(provider, tenant, channel)?,
250 ))
251 }
252
253 pub fn key(&self) -> Result<RecordKey, FleetKeyError> {
254 Self::key_for(&self.provider, &self.tenant, &self.channel)
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn person_roundtrips_and_keys() {
264 let p = Person {
265 display_name: "Ada".into(),
266 email: Some("ada@example.com".into()),
267 };
268 assert_eq!(Person::from_body(&p.to_body().unwrap()).unwrap(), p);
269 assert_eq!(Person::KIND, RecordKind::Person);
270 assert_eq!(
271 Person::key("p1").unwrap(),
272 RecordKey::new("fleet", "people", "p1")
273 );
274 assert!(Person::key("ada@example.com").is_err());
275 }
276
277 #[test]
278 fn identity_binding_roundtrips_and_keys() {
279 let b = IdentityBinding {
280 authenticator: Authenticator::Oidc {
281 issuer: "https://id.example.com".into(),
282 },
283 subject: "sub-1".into(),
284 person: "p1".into(),
285 handle: Some("ada".into()),
286 email: Some(VerifiedEmail {
287 address: "ada@example.com".into(),
288 verified: true,
289 }),
290 bound_at: 1_700_000_000_000,
291 bound_by: BindingOrigin::Operator(FleetActor::Service("ariel-cli".into())),
292 };
293 assert_eq!(
294 IdentityBinding::from_body(&b.to_body().unwrap()).unwrap(),
295 b
296 );
297 assert_eq!(IdentityBinding::KIND, RecordKind::IdentityBinding);
298 assert_eq!(
299 b.key().unwrap(),
300 RecordKey::new(
301 "fleet",
302 "identity-bindings",
303 "oidc:https%3A//id.example.com:sub-1"
304 )
305 );
306 }
307
308 #[test]
309 fn role_grant_roundtrips_and_keys() {
310 let g = RoleGrant {
311 person: "p1".into(),
312 scope: GrantScope::Workspace("caliban".into()),
313 role: FleetRole::Operator,
314 granted_by: FleetActor::Person("p0".into()),
315 granted_at: 1_700_000_000_000,
316 };
317 assert_eq!(RoleGrant::from_body(&g.to_body().unwrap()).unwrap(), g);
318 assert_eq!(RoleGrant::KIND, RecordKind::RoleGrant);
319 assert_eq!(
320 g.key().unwrap(),
321 RecordKey::new("fleet", "role-grants", "p1:workspace:caliban")
322 );
323 }
324
325 fn ops_channel() -> ChannelConfig {
326 ChannelConfig {
329 provider: "discord".into(),
330 tenant: "guild-1".into(),
331 channel: "42".into(),
332 follows: Follows::Fleet,
333 notify: NotifyPreset::Failures,
334 ceiling: FleetRole::Viewer,
335 }
336 }
337
338 #[test]
339 fn channel_config_roundtrips_and_keys() {
340 let c = ops_channel();
341 assert_eq!(ChannelConfig::from_body(&c.to_body().unwrap()).unwrap(), c);
342 assert_eq!(ChannelConfig::KIND, RecordKind::ChannelConfig);
343 assert_eq!(
344 c.key().unwrap(),
345 RecordKey::new("fleet", "channels", "discord:guild-1:42")
346 );
347 }
348
349 #[test]
350 fn channel_config_keys_include_the_tenant() {
351 let a = ops_channel();
354 let mut b = ops_channel();
355 b.tenant = "guild-2".into();
356 assert_ne!(a.key().unwrap(), b.key().unwrap());
357 }
358
359 #[test]
360 fn follows_stores_ariel_adr_0009_wire_shape() {
361 let mut c = ops_channel();
364 c.follows = Follows::workspaces(["caliban"]).unwrap();
365 let body = c.to_body().unwrap();
366 let v: serde_json::Value = serde_json::from_slice(body.bytes()).unwrap();
367 assert_eq!(
368 v["follows"],
369 serde_json::json!([{"kind": "workspaces", "names": ["caliban"]}])
370 );
371 assert_eq!(v["notify"], serde_json::json!("failures"));
372
373 let fleet_body = ops_channel().to_body().unwrap();
374 let v: serde_json::Value = serde_json::from_slice(fleet_body.bytes()).unwrap();
375 assert_eq!(v["follows"], serde_json::json!([{"kind": "fleet"}]));
376 assert_eq!(ChannelConfig::from_body(&body).unwrap(), c);
377 }
378
379 #[test]
380 fn follows_workspaces_must_name_at_least_one() {
381 assert_eq!(
382 Follows::workspaces(Vec::<String>::new()),
383 Err(EmptyFollowSet)
384 );
385 assert!(Follows::workspaces(["caliban", "gonzalo"]).is_ok());
386
387 let mut v: serde_json::Value =
389 serde_json::from_slice(ops_channel().to_body().unwrap().bytes()).unwrap();
390 v["follows"] = serde_json::json!([{"kind": "workspaces", "names": []}]);
391 let bytes = serde_json::to_vec(&v).unwrap();
392 assert!(
393 ChannelConfig::from_body(&gonzalo_core::Body::Inline(bytes)).is_err(),
394 "an empty workspace set must be rejected"
395 );
396 }
397
398 #[test]
399 fn channel_config_defaults_notify_all_and_ceiling_viewer() {
400 let json = serde_json::json!({
402 "provider": "discord",
403 "tenant": "guild-1",
404 "channel": "42",
405 "follows": [{"kind": "fleet"}],
406 });
407 let bytes = serde_json::to_vec(&json).unwrap();
408 let c = ChannelConfig::from_body(&gonzalo_core::Body::Inline(bytes)).unwrap();
409 assert_eq!(c.notify, NotifyPreset::All);
410 assert_eq!(c.ceiling, FleetRole::Viewer);
411
412 let missing_follows = serde_json::json!({
413 "provider": "discord",
414 "tenant": "guild-1",
415 "channel": "42",
416 });
417 let bytes = serde_json::to_vec(&missing_follows).unwrap();
418 assert!(
419 ChannelConfig::from_body(&gonzalo_core::Body::Inline(bytes)).is_err(),
420 "follows is required"
421 );
422 }
423
424 #[test]
427 fn atomic_wrapped_field_stores_as_one_element_array() {
428 let g = RoleGrant {
429 person: "p1".into(),
430 scope: GrantScope::Fleet,
431 role: FleetRole::Operator,
432 granted_by: FleetActor::Person("p0".into()),
433 granted_at: 1_700_000_000_000,
434 };
435 let body = g.to_body().unwrap();
436 let text = String::from_utf8(body.bytes().to_vec()).unwrap();
437 assert!(
438 text.contains(r#""granted_by":[{"#),
439 "granted_by must be stored as a one-element array, got: {text}"
440 );
441 let v: serde_json::Value = serde_json::from_str(&text).unwrap();
442 assert!(v["granted_by"].as_array().unwrap().len() == 1);
443 assert_eq!(RoleGrant::from_body(&body).unwrap(), g);
445 }
446
447 #[test]
448 fn atomic_wrapped_field_rejects_empty_and_multi_element_arrays() {
449 let g = RoleGrant {
450 person: "p1".into(),
451 scope: GrantScope::Fleet,
452 role: FleetRole::Operator,
453 granted_by: FleetActor::Person("p0".into()),
454 granted_at: 1_700_000_000_000,
455 };
456 let mut v: serde_json::Value =
457 serde_json::from_slice(g.to_body().unwrap().bytes()).unwrap();
458
459 v["granted_by"] = serde_json::json!([]);
460 let bytes = serde_json::to_vec(&v).unwrap();
461 assert!(
462 RoleGrant::from_body(&gonzalo_core::Body::Inline(bytes)).is_err(),
463 "an empty array must be rejected"
464 );
465
466 v["granted_by"] = serde_json::json!([{"Person": "p0"}, {"Person": "p0"}]);
467 let bytes = serde_json::to_vec(&v).unwrap();
468 assert!(
469 RoleGrant::from_body(&gonzalo_core::Body::Inline(bytes)).is_err(),
470 "a two-element array must be rejected"
471 );
472 }
473
474 fn merge_bodies(
477 kind: RecordKind,
478 base: &gonzalo_core::Body,
479 ours: &gonzalo_core::Body,
480 theirs: &gonzalo_core::Body,
481 ) -> gonzalo_core::MergeOutcome {
482 gonzalo_core::merge(kind.merge_class(), base, ours, theirs)
483 }
484
485 #[test]
486 fn role_grant_variant_mix_on_granted_by_needs_resolution() {
487 let base = RoleGrant {
493 person: "p1".into(),
494 scope: GrantScope::Fleet,
495 role: FleetRole::Operator,
496 granted_by: FleetActor::Person("p0".into()),
497 granted_at: 1_700_000_000_000,
498 };
499 let mut ours = base.clone();
500 ours.granted_by = FleetActor::Service("ariel".into());
501 let mut theirs = base.clone();
502 theirs.granted_by = FleetActor::Unlinked {
503 authenticator: Authenticator::Discord,
504 subject: "1234".into(),
505 };
506
507 let outcome = merge_bodies(
508 RecordKind::RoleGrant,
509 &base.to_body().unwrap(),
510 &ours.to_body().unwrap(),
511 &theirs.to_body().unwrap(),
512 );
513 assert_eq!(outcome, gonzalo_core::MergeOutcome::NeedsResolution);
514 }
515
516 #[test]
517 fn identity_binding_email_half_mix_needs_resolution() {
518 let base = IdentityBinding {
523 authenticator: Authenticator::Discord,
524 subject: "sub-1".into(),
525 person: "p1".into(),
526 handle: None,
527 email: Some(VerifiedEmail {
528 address: "a@x".into(),
529 verified: false,
530 }),
531 bound_at: 1_700_000_000_000,
532 bound_by: BindingOrigin::Operator(FleetActor::Service("ariel".into())),
533 };
534 let mut ours = base.clone();
535 ours.email = Some(VerifiedEmail {
536 address: "b@x".into(),
537 verified: false,
538 });
539 let mut theirs = base.clone();
540 theirs.email = Some(VerifiedEmail {
541 address: "a@x".into(),
542 verified: true,
543 });
544
545 let outcome = merge_bodies(
546 RecordKind::IdentityBinding,
547 &base.to_body().unwrap(),
548 &ours.to_body().unwrap(),
549 &theirs.to_body().unwrap(),
550 );
551 assert_eq!(outcome, gonzalo_core::MergeOutcome::NeedsResolution);
552 }
553
554 #[test]
555 fn identity_binding_disjoint_atomic_fields_merge() {
556 let base = IdentityBinding {
560 authenticator: Authenticator::Discord,
561 subject: "sub-1".into(),
562 person: "p1".into(),
563 handle: None,
564 email: None,
565 bound_at: 1_700_000_000_000,
566 bound_by: BindingOrigin::Operator(FleetActor::Service("ariel".into())),
567 };
568 let mut ours = base.clone();
569 ours.handle = Some("ada".into());
570 let mut theirs = base.clone();
571 theirs.email = Some(VerifiedEmail {
572 address: "a@x".into(),
573 verified: true,
574 });
575
576 let outcome = merge_bodies(
577 RecordKind::IdentityBinding,
578 &base.to_body().unwrap(),
579 &ours.to_body().unwrap(),
580 &theirs.to_body().unwrap(),
581 );
582 let gonzalo_core::MergeOutcome::Merged(body) = outcome else {
583 panic!("expected a merge, got NeedsResolution");
584 };
585 let merged = IdentityBinding::from_body(&body).unwrap();
586 assert_eq!(merged.handle, Some("ada".into()));
587 assert_eq!(
588 merged.email,
589 Some(VerifiedEmail {
590 address: "a@x".into(),
591 verified: true,
592 })
593 );
594 }
595
596 #[test]
597 fn role_grant_one_sided_atomic_change_merges() {
598 let base = RoleGrant {
600 person: "p1".into(),
601 scope: GrantScope::Fleet,
602 role: FleetRole::Operator,
603 granted_by: FleetActor::Person("p0".into()),
604 granted_at: 1_700_000_000_000,
605 };
606 let mut ours = base.clone();
607 ours.granted_by = FleetActor::Service("ariel".into());
608 let theirs = base.clone();
609
610 let outcome = merge_bodies(
611 RecordKind::RoleGrant,
612 &base.to_body().unwrap(),
613 &ours.to_body().unwrap(),
614 &theirs.to_body().unwrap(),
615 );
616 let gonzalo_core::MergeOutcome::Merged(body) = outcome else {
617 panic!("expected a merge, got NeedsResolution");
618 };
619 let merged = RoleGrant::from_body(&body).unwrap();
620 assert_eq!(merged.granted_by, FleetActor::Service("ariel".into()));
621 }
622
623 #[test]
624 fn channel_follows_changed_on_both_sides_needs_resolution() {
625 let base = ops_channel();
628 let mut ours = base.clone();
629 ours.follows = Follows::workspaces(["caliban"]).unwrap();
630 let mut theirs = base.clone();
631 theirs.follows = Follows::workspaces(["gonzalo"]).unwrap();
632
633 let outcome = merge_bodies(
634 RecordKind::ChannelConfig,
635 &base.to_body().unwrap(),
636 &ours.to_body().unwrap(),
637 &theirs.to_body().unwrap(),
638 );
639 assert_eq!(outcome, gonzalo_core::MergeOutcome::NeedsResolution);
640 }
641
642 #[test]
643 fn channel_disjoint_notify_and_follows_edits_merge() {
644 let base = ops_channel();
646 let mut ours = base.clone();
647 ours.follows = Follows::workspaces(["caliban"]).unwrap();
648 let mut theirs = base.clone();
649 theirs.notify = NotifyPreset::All;
650
651 let outcome = merge_bodies(
652 RecordKind::ChannelConfig,
653 &base.to_body().unwrap(),
654 &ours.to_body().unwrap(),
655 &theirs.to_body().unwrap(),
656 );
657 let gonzalo_core::MergeOutcome::Merged(body) = outcome else {
658 panic!("expected a merge, got NeedsResolution");
659 };
660 let merged = ChannelConfig::from_body(&body).unwrap();
661 assert_eq!(merged.follows, Follows::workspaces(["caliban"]).unwrap());
662 assert_eq!(merged.notify, NotifyPreset::All);
663 }
664}