@9 Invites: joining and contributing need one
Files
mVISION.md+3 −2mdocs/model.md+7 −2msrc/api.rs+23 −1msrc/backend.rs+24 −0msrc/cli.rs+38 −4msrc/model.rs+61 −1msrc/store.rs+40 −7msrc/wire.rs+3 −1
VISION.mdmodified+3 −2
| 10 | 10 | work while it's in progress, not only accept or reject it at the end. |
| 11 | 11 | - **Agent.** Writes code. Every agent acts for a person, and grove records both: which |
| 12 | 12 | agent made a change and on whose behalf. |
| 13 | - **Contributor.** Anyone, through their agents. Anyone can propose a change to a public | |
| 14 | project. The owner is under no obligation to take it. | |
| 13 | - **Contributor.** Someone the owner invited, working through their agents. They can propose | |
| 14 | changes and take part in discussions; the owner is under no obligation to take anything. | |
| 15 | Joining the server and contributing to a project both take an invite. | |
| 15 | 16 | |
| 16 | 17 | ## Changes |
| 17 | 18 |
docs/model.mdmodified+7 −2
| 34 | 34 | A project has an owner (a person), a name unique within the owner, a description, and a |
| 35 | 35 | visibility: |
| 36 | 36 | |
| 37 | - **public:** anyone can read it, open changes and join discussions. | |
| 38 | - **private:** only the owner and people they invite. | |
| 37 | - **public:** anyone can read it. | |
| 38 | - **private:** only the owner and the project's contributors can read it. | |
| 39 | ||
| 40 | Taking part (opening changes, revising, discussing, attaching evidence) is for the owner and | |
| 41 | **contributors**: people the owner invited. An **invite** is a one-time code, created by a | |
| 42 | person in person. It lets someone join the server, and if it names a project, makes them a | |
| 43 | contributor to it. Nobody joins the server without one. | |
| 39 | 44 | |
| 40 | 45 | Only the owner (and people they make co-owners) can land, decline, or change settings, and |
| 41 | 46 | only in person: these are decisions, so an agent acting for the owner can't make them. |
src/api.rsmodified+23 −1
| 130 | 130 | |
| 131 | 131 | // ── People, agents, tokens, groups ── |
| 132 | 132 | ("POST", ["api", "people"]) => { |
| 133 | // Anyone can join: a person, their own token, and optionally an agent with its token. | |
| 133 | // Joining takes an invite: a person, their own token, and optionally an agent | |
| 134 | // with its token. A project invite also makes them a contributor. | |
| 134 | 135 | let b = body(req)?; |
| 135 | 136 | let username = field(&b, "username")?.to_string(); |
| 137 | let invite = field(&b, "invite")?.to_string(); | |
| 136 | 138 | let display = b.get("display_name").and_then(Value::as_str).unwrap_or(&username).to_string(); |
| 137 | 139 | let agent_name = b.get("agent").and_then(Value::as_str).map(str::to_string); |
| 138 | 140 | let mut store = self.store(); |
| 141 | if !store.invite_open(&invite) { | |
| 142 | return Err(err(403, "that invite doesn't exist or was already used")); | |
| 143 | } | |
| 139 | 144 | store.add_person(&username, &display).map_err(from_store)?; |
| 145 | store.use_invite(&username, &invite).map_err(from_store)?; | |
| 140 | 146 | let person_token = store.issue_token(&username, None).map_err(from_store)?; |
| 141 | 147 | let (agent, agent_token) = match agent_name { |
| 142 | 148 | Some(name) => { |
| 147 | 153 | None => (Value::Null, Value::Null), |
| 148 | 154 | }; |
| 149 | 155 | ok(obj([("person", username.into()), ("person_token", person_token.into()), ("agent", agent), ("agent_token", agent_token)])) |
| 156 | } | |
| 157 | ("POST", ["api", "invites"]) => { | |
| 158 | let a = self.required(req)?; | |
| 159 | let b = body(req)?; | |
| 160 | let project = match (b.get("owner").and_then(Value::as_str), b.get("project").and_then(Value::as_str)) { | |
| 161 | (Some(o), Some(n)) => Some((o.to_string(), n.to_string())), | |
| 162 | _ => None, | |
| 163 | }; | |
| 164 | let code = self.store().create_invite(&a, project.as_ref().map(|(o, n)| (o.as_str(), n.as_str()))).map_err(from_store)?; | |
| 165 | ok(obj([("invite", code.into())])) | |
| 166 | } | |
| 167 | ("POST", ["api", "invites", "accept"]) => { | |
| 168 | let a = self.required(req)?; | |
| 169 | let code = field(&body(req)?, "invite")?.to_string(); | |
| 170 | let project = self.store().use_invite(&a.person, &code).map_err(from_store)?; | |
| 171 | ok(obj([("project", project.map(|(o, n)| format!("{o}/{n}")).into())])) | |
| 150 | 172 | } |
| 151 | 173 | ("POST", ["api", "agents"]) => { |
| 152 | 174 | let a = self.required(req)?; |
src/backend.rsmodified+24 −0
| 32 | 32 | fn create_group(&mut self, by: &Actor, name: &str) -> R<()>; |
| 33 | 33 | fn add_member(&mut self, by: &Actor, group: &str, person: &str) -> R<()>; |
| 34 | 34 | fn move_project(&mut self, by: &Actor, owner: &str, name: &str, to: &str) -> R<()>; |
| 35 | /// An invite code, to join and (with a project) to contribute to it. | |
| 36 | fn invite(&mut self, by: &Actor, project: Option<(&str, &str)>) -> R<String>; | |
| 37 | /// Use an invite as an existing person. Returns the project it grants, if any. | |
| 38 | fn accept(&mut self, by: &Actor, code: &str) -> R<Option<String>>; | |
| 35 | 39 | } |
| 36 | 40 | |
| 37 | 41 | // ── On this machine ──────────────────────────────────────── |
| 88 | 92 | fn move_project(&mut self, by: &Actor, owner: &str, name: &str, to: &str) -> R<()> { |
| 89 | 93 | s(self.0.move_project(by, owner, name, to)) |
| 90 | 94 | } |
| 95 | fn invite(&mut self, by: &Actor, project: Option<(&str, &str)>) -> R<String> { | |
| 96 | s(self.0.create_invite(by, project)) | |
| 97 | } | |
| 98 | fn accept(&mut self, by: &Actor, code: &str) -> R<Option<String>> { | |
| 99 | s(self.0.use_invite(&by.person, code)).map(|p| p.map(|(o, n)| format!("{o}/{n}"))) | |
| 100 | } | |
| 91 | 101 | } |
| 92 | 102 | |
| 93 | 103 | // ── A grove server ───────────────────────────────────────── |
| 267 | 277 | fn move_project(&mut self, by: &Actor, owner: &str, name: &str, to: &str) -> R<()> { |
| 268 | 278 | self.client(by)?.post(&Self::path(owner, name, "/move"), &obj([("to", to.into())]))?; |
| 269 | 279 | Ok(()) |
| 280 | } | |
| 281 | ||
| 282 | fn invite(&mut self, by: &Actor, project: Option<(&str, &str)>) -> R<String> { | |
| 283 | let body = match project { | |
| 284 | Some((o, n)) => obj([("owner", o.into()), ("project", n.into())]), | |
| 285 | None => obj([]), | |
| 286 | }; | |
| 287 | let v = self.client(by)?.post("/api/invites", &body)?; | |
| 288 | v.get("invite").and_then(Value::as_str).map(str::to_string).ok_or_else(|| "the server sent no invite".into()) | |
| 289 | } | |
| 290 | ||
| 291 | fn accept(&mut self, by: &Actor, code: &str) -> R<Option<String>> { | |
| 292 | let v = self.client(by)?.post("/api/invites/accept", &obj([("invite", code.into())]))?; | |
| 293 | Ok(v.get("project").and_then(Value::as_str).map(str::to_string)) | |
| 270 | 294 | } |
| 271 | 295 | } |
src/cli.rsmodified+38 −4
| 284 | 284 | let rest = &argv[argv.len().min(2)..]; |
| 285 | 285 | let args = Args::parse( |
| 286 | 286 | rest, |
| 287 | &["-m", "--message", "--note", "--agent", "--display", "--store", "--type", "--title", "--url", "--owner", "--token", "--person-token", "--addr"], | |
| 287 | &["-m", "--message", "--note", "--agent", "--display", "--store", "--type", "--title", "--url", "--owner", "--token", "--person-token", "--addr", "--invite"], | |
| 288 | 288 | ); |
| 289 | 289 | match cmd { |
| 290 | 290 | "setup" => setup(&args), |
| 291 | 291 | "login" => login(&args), |
| 292 | 292 | "join" => join(&args), |
| 293 | "invite" => invite(&args), | |
| 294 | "accept" => accept(&args), | |
| 293 | 295 | "watch" => watch(&args), |
| 294 | 296 | "init" => init(&args), |
| 295 | 297 | "checkout" => checkout(&args), |
| 324 | 326 | |
| 325 | 327 | const HELP: &str = "grove: where agents build software for people |
| 326 | 328 | |
| 327 | join <server> <username> [--agent NAME] create an account on a grove server | |
| 329 | join <server> <username> --invite CODE [--agent NAME] join a grove server | |
| 330 | accept CODE use a project invite on an account you have | |
| 331 | invite [OWNER/NAME | --server] invite someone (in person): to this project, or just to join | |
| 328 | 332 | login <server> --token T [--person-token P] work against a grove server |
| 329 | 333 | init <name> [--owner GROUP] [--private] [-m M] make this folder a new project |
| 330 | 334 | checkout <owner/name> [DIR] get a project's files |
| 416 | 420 | check_config_is_ours()?; |
| 417 | 421 | let (server, username) = match (args.pos(0), args.pos(1)) { |
| 418 | 422 | (Some(s), Some(u)) => (s, u), |
| 419 | _ => return e!("usage: grove join <server> <username> [--agent NAME]"), | |
| 423 | _ => return e!("usage: grove join <server> <username> --invite CODE [--agent NAME]"), | |
| 420 | 424 | }; |
| 421 | let mut body = vec![("username", Value::from(username))]; | |
| 425 | let code = args.value(&["--invite"]).ok_or("joining needs an invite: ask someone on the server for one")?; | |
| 426 | let mut body = vec![("username", Value::from(username)), ("invite", Value::from(code))]; | |
| 422 | 427 | if let Some(d) = args.value(&["--display"]) { |
| 423 | 428 | body.push(("display_name", d.into())); |
| 424 | 429 | } |
| 439 | 444 | match args.value(&["--agent"]) { |
| 440 | 445 | Some(a) => println!("joined {server} as {username}; {a} acts for you"), |
| 441 | 446 | None => println!("joined {server} as {username}"), |
| 447 | } | |
| 448 | Ok(()) | |
| 449 | } | |
| 450 | ||
| 451 | fn invite(args: &Args) -> Result<()> { | |
| 452 | let cfg = Config::load()?; | |
| 453 | let project = match (args.pos(0), args.has("--server")) { | |
| 454 | (Some(full), _) => Some(full.split_once('/').map(|(o, n)| (o.to_string(), n.to_string())).ok_or("give the project as owner/name")?), | |
| 455 | (None, true) => None, | |
| 456 | (None, false) => { | |
| 457 | let local = Local::find().map_err(|_| "say which project (owner/name), or --server for an invite only to join".to_string())?; | |
| 458 | Some((local.owner, local.project)) | |
| 459 | } | |
| 460 | }; | |
| 461 | let code = connect(&cfg)?.invite(&cfg.in_person(), project.as_ref().map(|(o, n)| (o.as_str(), n.as_str())))?; | |
| 462 | match &project { | |
| 463 | Some((o, n)) => eprintln!("invite to {o}/{n} (works once):"), | |
| 464 | None => eprintln!("invite to join (works once):"), | |
| 465 | } | |
| 466 | println!("{code}"); | |
| 467 | Ok(()) | |
| 468 | } | |
| 469 | ||
| 470 | fn accept(args: &Args) -> Result<()> { | |
| 471 | let cfg = Config::load()?; | |
| 472 | let code = args.pos(0).ok_or("usage: grove accept CODE")?; | |
| 473 | match connect(&cfg)?.accept(&cfg.actor(), code)? { | |
| 474 | Some(p) => println!("you can now contribute to {p}"), | |
| 475 | None => println!("invite used"), | |
| 442 | 476 | } |
| 443 | 477 | Ok(()) |
| 444 | 478 | } |
src/model.rsmodified+61 −1
| 101 | 101 | pub at: i64, |
| 102 | 102 | } |
| 103 | 103 | |
| 104 | /// An invitation, stored as the BLAKE3 hash of its code. It lets someone join the server, and | |
| 105 | /// if it names a project, makes them a contributor to it. Each code works once. | |
| 106 | #[derive(Clone, Debug)] | |
| 107 | pub struct Invite { | |
| 108 | pub hash: String, | |
| 109 | pub by: String, | |
| 110 | pub project: Option<(String, String)>, | |
| 111 | pub used_by: Option<String>, | |
| 112 | pub at: i64, | |
| 113 | } | |
| 114 | ||
| 104 | 115 | /// An access token, stored as the BLAKE3 hash of its secret. A token acts as its person, or |
| 105 | 116 | /// as one of their agents. |
| 106 | 117 | #[derive(Clone, Debug)] |
| 207 | 218 | pub public: bool, |
| 208 | 219 | pub points: Vec<Point>, |
| 209 | 220 | pub changes: Vec<Change>, |
| 221 | /// People invited to take part: they can open changes, revise, discuss and attach evidence. | |
| 222 | pub contributors: Vec<String>, | |
| 210 | 223 | pub at: i64, |
| 211 | 224 | } |
| 212 | 225 | |
| 231 | 244 | pub agents: Vec<Agent>, |
| 232 | 245 | pub groups: Vec<Group>, |
| 233 | 246 | pub tokens: Vec<Token>, |
| 247 | pub invites: Vec<Invite>, | |
| 234 | 248 | pub projects: Vec<Project>, |
| 235 | 249 | pub records: u64, |
| 236 | 250 | } |
| 296 | 310 | pub fn owns(&self, owner: &str, person: &str) -> bool { |
| 297 | 311 | owner == person || self.group(owner).is_some_and(|g| g.members.iter().any(|m| m == person)) |
| 298 | 312 | } |
| 313 | /// Owners (and their group's members) and invited contributors take part in a project. | |
| 314 | pub fn can_contribute(&self, owner: &str, name: &str, person: &str) -> bool { | |
| 315 | self.owns(owner, person) || self.project(owner, name).is_some_and(|p| p.contributors.iter().any(|c| c == person)) | |
| 316 | } | |
| 317 | /// An unused invite, by its hash. | |
| 318 | pub fn open_invite(&self, hash: &str) -> Option<&Invite> { | |
| 319 | self.invites.iter().find(|i| i.hash == hash && i.used_by.is_none()) | |
| 320 | } | |
| 299 | 321 | /// Who a token acts as, if it's valid. |
| 300 | 322 | pub fn token_actor(&self, hash: &str) -> Option<Actor> { |
| 301 | 323 | let t = self.tokens.iter().find(|t| t.hash == hash && !t.revoked)?; |
| 330 | 352 | _ => return Err(format!("agent {agent} can't act for {}", by.person)), |
| 331 | 353 | } |
| 332 | 354 | } |
| 355 | if matches!(t.as_str(), "change" | "revision" | "state" | "say" | "evidence") { | |
| 356 | let (owner, name) = (str_field(rec, "owner")?, str_field(rec, "project")?); | |
| 357 | if !self.can_contribute(&owner, &name, &by.person) { | |
| 358 | return Err(format!("{} isn't a contributor to {owner}/{name}; the owner can send an invite", by.person)); | |
| 359 | } | |
| 360 | } | |
| 333 | 361 | match t.as_str() { |
| 334 | 362 | "person" => { |
| 335 | 363 | let username = str_field(rec, "username")?; |
| 353 | 381 | let a = self.agents.iter_mut().find(|a| a.id == id && a.person == by.person).ok_or("no such agent")?; |
| 354 | 382 | a.revoked = true; |
| 355 | 383 | } |
| 384 | "invite" => { | |
| 385 | if by.agent.is_some() { | |
| 386 | return Err("invites are sent in person".into()); | |
| 387 | } | |
| 388 | let hash = str_field(rec, "hash")?; | |
| 389 | let project = match (opt_str(rec, "owner")?, opt_str(rec, "project")?) { | |
| 390 | (Some(o), Some(n)) => { | |
| 391 | if !self.owns(&o, &by.person) || self.project(&o, &n).is_none() { | |
| 392 | return Err(format!("{} can't invite people to {o}/{n}", by.person)); | |
| 393 | } | |
| 394 | Some((o, n)) | |
| 395 | } | |
| 396 | (None, None) => None, | |
| 397 | _ => return Err("an invite names both owner and project, or neither".into()), | |
| 398 | }; | |
| 399 | if self.invites.iter().any(|i| i.hash == hash) { | |
| 400 | return Err("invite exists".into()); | |
| 401 | } | |
| 402 | self.invites.push(Invite { hash, by: by.person.clone(), project, used_by: None, at }); | |
| 403 | } | |
| 404 | "invite.use" => { | |
| 405 | let hash = str_field(rec, "hash")?; | |
| 406 | let person = by.person.clone(); | |
| 407 | let invite = self.open_invite(&hash).ok_or("that invite doesn't exist or was already used")?.clone(); | |
| 408 | if let Some((o, n)) = &invite.project { | |
| 409 | let p = self.project_mut(o, n)?; | |
| 410 | if !p.contributors.contains(&person) { | |
| 411 | p.contributors.push(person.clone()); | |
| 412 | } | |
| 413 | } | |
| 414 | self.invites.iter_mut().find(|i| i.hash == hash).unwrap().used_by = Some(person); | |
| 415 | } | |
| 356 | 416 | "token" => { |
| 357 | 417 | // Tokens are issued by a person, in person, for themselves or one of their agents. |
| 358 | 418 | if by.agent.is_some() { |
| 414 | 474 | return Err(format!("project {owner}/{name} exists")); |
| 415 | 475 | } |
| 416 | 476 | let public = matches!(rec.get("public"), Some(Value::Bool(true))); |
| 417 | self.projects.push(Project { owner, name, public, points: Vec::new(), changes: Vec::new(), at }); | |
| 477 | self.projects.push(Project { owner, name, public, points: Vec::new(), changes: Vec::new(), contributors: Vec::new(), at }); | |
| 418 | 478 | } |
| 419 | 479 | "project.move" => { |
| 420 | 480 | // A project changes owner; its history goes with it. |
src/store.rsmodified+40 −7
| 171 | 171 | Ok(secret) |
| 172 | 172 | } |
| 173 | 173 | |
| 174 | /// Create an invite (in person). With a project, it also makes whoever uses it a | |
| 175 | /// contributor. Returns the code, shown once; only its hash is kept. | |
| 176 | pub fn create_invite(&mut self, by: &Actor, project: Option<(&str, &str)>) -> Result<String> { | |
| 177 | let code = format!("invite_{}", crate::random::hex(16)); | |
| 178 | let mut fields = vec![("hash", token_hash(&code).into())]; | |
| 179 | if let Some((o, n)) = project { | |
| 180 | fields.push(("owner", o.into())); | |
| 181 | fields.push(("project", n.into())); | |
| 182 | } | |
| 183 | self.write(by, Self::rec("invite", fields))?; | |
| 184 | Ok(code) | |
| 185 | } | |
| 186 | ||
| 187 | /// Is this invite code usable? | |
| 188 | pub fn invite_open(&self, code: &str) -> bool { | |
| 189 | self.state.open_invite(&token_hash(code)).is_some() | |
| 190 | } | |
| 191 | ||
| 192 | /// Use an invite as `person`. Returns the project it grants, if any. | |
| 193 | pub fn use_invite(&mut self, person: &str, code: &str) -> Result<Option<(String, String)>> { | |
| 194 | let hash = token_hash(code); | |
| 195 | let project = self.state.open_invite(&hash).ok_or_else(|| Error::Refused("that invite doesn't exist or was already used".into()))?.project.clone(); | |
| 196 | self.write(&Actor::person(person), Self::rec("invite.use", vec![("hash", hash.into())]))?; | |
| 197 | Ok(project) | |
| 198 | } | |
| 199 | ||
| 174 | 200 | /// Who a token's secret acts as. |
| 175 | 201 | pub fn authenticate(&self, secret: &str) -> Option<Actor> { |
| 176 | 202 | self.state.token_actor(&token_hash(secret)) |
| 228 | 254 | |
| 229 | 255 | /// Open a change as an idea: an intent, no code yet. Its first revision makes it a draft. |
| 230 | 256 | pub fn open_change(&mut self, by: &Actor, owner: &str, name: &str, intent: &str) -> Result<u64> { |
| 231 | let p = self.project(owner, name)?; | |
| 232 | if !p.public && !self.state.owns(&p.owner, &by.person) { | |
| 233 | return refused(format!("{owner}/{name} is private")); | |
| 234 | } | |
| 235 | let number = p.changes.len() as u64 + 1; | |
| 257 | let number = self.project(owner, name)?.changes.len() as u64 + 1; | |
| 236 | 258 | self.write(by, Self::rec("change", vec![("owner", owner.into()), ("project", name.into()), ("number", number.into()), ("intent", intent.into()), ("state", "idea".into())]))?; |
| 237 | 259 | Ok(number) |
| 238 | 260 | } |
| 485 | 507 | store.move_project(&anton, "anton", "grove", "letterpress").unwrap(); |
| 486 | 508 | assert!(store.project("anton", "grove").is_err()); |
| 487 | 509 | assert_eq!(store.project("letterpress", "grove").unwrap().tip(), 1); |
| 488 | // Members decide for the group; others don't. | |
| 510 | // Members decide for the group; others don't. Bea needs an invite to take part at all. | |
| 489 | 511 | let t = tree(&store, &[("README", "group")]); |
| 512 | assert!(store.open_change(&bea, "letterpress", "grove", "from bea").is_err()); | |
| 513 | let code = store.create_invite(&anton, Some(("letterpress", "grove"))).unwrap(); | |
| 514 | store.use_invite("bea", &code).unwrap(); | |
| 490 | 515 | let c = store.open_change(&bea, "letterpress", "grove", "from bea").unwrap(); |
| 491 | 516 | store.revise(&bea, "letterpress", "grove", c, 1, &[Step { id: "s".into(), snapshot: t, message: "m".into() }], None).unwrap(); |
| 492 | 517 | store.set_state(&bea, "letterpress", "grove", c, ChangeState::Proposed).unwrap(); |
| 505 | 530 | let (mut store, _, _) = setup(&dir); |
| 506 | 531 | store.add_person("visitor", "Visitor").unwrap(); |
| 507 | 532 | let visitor = Actor::person("visitor"); |
| 508 | // Anyone can propose to a public project, but not land. | |
| 533 | // Without an invite, a stranger can't even open a change. | |
| 534 | assert!(store.open_change(&visitor, "anton", "grove", "uninvited").is_err()); | |
| 535 | // Agents can't send invites; the owner, in person, can. | |
| 536 | let agent = Actor::agent("anton", &store.state().agents[0].id.clone()); | |
| 537 | assert!(store.create_invite(&agent, Some(("anton", "grove"))).is_err()); | |
| 538 | let code = store.create_invite(&Actor::person("anton"), Some(("anton", "grove"))).unwrap(); | |
| 539 | assert_eq!(store.use_invite("visitor", &code).unwrap(), Some(("anton".into(), "grove".into()))); | |
| 540 | assert!(store.use_invite("visitor", &code).is_err(), "an invite works once"); | |
| 541 | // An invited contributor can propose, but not land. | |
| 509 | 542 | let c = store.open_change(&visitor, "anton", "grove", "An idea from outside").unwrap(); |
| 510 | 543 | let t = tree(&store, &[("README", "outside")]); |
| 511 | 544 | store.revise(&visitor, "anton", "grove", c, 1, &[Step { id: "s".into(), snapshot: t, message: "m".into() }], None).unwrap(); |
src/wire.rsmodified+3 −1
| 41 | 41 | ("owner", p.owner.clone().into()), |
| 42 | 42 | ("name", p.name.clone().into()), |
| 43 | 43 | ("public", p.public.into()), |
| 44 | ("contributors", Value::Array(p.contributors.iter().map(|c| Value::from(c.as_str())).collect())), | |
| 44 | 45 | ("at", p.at.into()), |
| 45 | 46 | ("points", Value::Array(p.points.iter().map(point).collect())), |
| 46 | 47 | ("changes", Value::Array(p.changes.iter().map(change).collect())), |
| 244 | 245 | _ => Vec::new(), |
| 245 | 246 | }; |
| 246 | 247 | let public = matches!(v.get("public"), Some(Value::Bool(true))); |
| 247 | Ok((Project { owner: s(v, "owner")?, name: s(v, "name")?, public, points, changes, at: n(v, "at")? }, agents)) | |
| 248 | let contributors = arr(v, "contributors").iter().filter_map(|c| c.as_str().map(str::to_string)).collect(); | |
| 249 | Ok((Project { owner: s(v, "owner")?, name: s(v, "name")?, public, points, changes, contributors, at: n(v, "at")? }, agents)) | |
| 248 | 250 | } |
| 249 | 251 | |
| 250 | 252 | /// Objects in transit: each is `length (u32 LE), stored bytes`. |