grove

#8 Access control: invites to join and to contribute

landed
proposed by claude for antonky · · r1 landed as @9
Open sign-up is gone. An invite is a one-time code a person creates in person; it lets someone join, and if it names a project, makes them a contributor. Opening changes, revising, discussing and attaching evidence are limited to owners and contributors, enforced in the model. Checked: 31 tests, and a local server run covering refused joins, agents unable to invite, single-use invites, and a contributor proposing only after accepting a project invite.
Invites: joining and contributing need one

Files

VISION.mdmodified+3 −2
1010 work while it's in progress, not only accept or reject it at the end.
1111- **Agent.** Writes code. Every agent acts for a person, and grove records both: which
1212 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.
1516
1617## Changes
1718
docs/model.mdmodified+7 −2
3434A project has an owner (a person), a name unique within the owner, a description, and a
3535visibility:
3636
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
40Taking 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
42person in person. It lets someone join the server, and if it names a project, makes them a
43contributor to it. Nobody joins the server without one.
3944
4045Only the owner (and people they make co-owners) can land, decline, or change settings, and
4146only in person: these are decisions, so an agent acting for the owner can't make them.
src/api.rsmodified+23 −1
130130
131131 // ── People, agents, tokens, groups ──
132132 ("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.
134135 let b = body(req)?;
135136 let username = field(&b, "username")?.to_string();
137 let invite = field(&b, "invite")?.to_string();
136138 let display = b.get("display_name").and_then(Value::as_str).unwrap_or(&username).to_string();
137139 let agent_name = b.get("agent").and_then(Value::as_str).map(str::to_string);
138140 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 }
139144 store.add_person(&username, &display).map_err(from_store)?;
145 store.use_invite(&username, &invite).map_err(from_store)?;
140146 let person_token = store.issue_token(&username, None).map_err(from_store)?;
141147 let (agent, agent_token) = match agent_name {
142148 Some(name) => {
147153 None => (Value::Null, Value::Null),
148154 };
149155 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())]))
150172 }
151173 ("POST", ["api", "agents"]) => {
152174 let a = self.required(req)?;
src/backend.rsmodified+24 −0
3232 fn create_group(&mut self, by: &Actor, name: &str) -> R<()>;
3333 fn add_member(&mut self, by: &Actor, group: &str, person: &str) -> R<()>;
3434 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>>;
3539}
3640
3741// ── On this machine ────────────────────────────────────────
8892 fn move_project(&mut self, by: &Actor, owner: &str, name: &str, to: &str) -> R<()> {
8993 s(self.0.move_project(by, owner, name, to))
9094 }
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 }
91101}
92102
93103// ── A grove server ─────────────────────────────────────────
267277 fn move_project(&mut self, by: &Actor, owner: &str, name: &str, to: &str) -> R<()> {
268278 self.client(by)?.post(&Self::path(owner, name, "/move"), &obj([("to", to.into())]))?;
269279 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))
270294 }
271295}
src/cli.rsmodified+38 −4
284284 let rest = &argv[argv.len().min(2)..];
285285 let args = Args::parse(
286286 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"],
288288 );
289289 match cmd {
290290 "setup" => setup(&args),
291291 "login" => login(&args),
292292 "join" => join(&args),
293 "invite" => invite(&args),
294 "accept" => accept(&args),
293295 "watch" => watch(&args),
294296 "init" => init(&args),
295297 "checkout" => checkout(&args),
324326
325327const HELP: &str = "grove: where agents build software for people
326328
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
328332 login <server> --token T [--person-token P] work against a grove server
329333 init <name> [--owner GROUP] [--private] [-m M] make this folder a new project
330334 checkout <owner/name> [DIR] get a project's files
416420 check_config_is_ours()?;
417421 let (server, username) = match (args.pos(0), args.pos(1)) {
418422 (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]"),
420424 };
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))];
422427 if let Some(d) = args.value(&["--display"]) {
423428 body.push(("display_name", d.into()));
424429 }
439444 match args.value(&["--agent"]) {
440445 Some(a) => println!("joined {server} as {username}; {a} acts for you"),
441446 None => println!("joined {server} as {username}"),
447 }
448 Ok(())
449}
450
451fn 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
470fn 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"),
442476 }
443477 Ok(())
444478}
src/model.rsmodified+61 −1
101101 pub at: i64,
102102}
103103
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)]
107pub 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
104115/// An access token, stored as the BLAKE3 hash of its secret. A token acts as its person, or
105116/// as one of their agents.
106117#[derive(Clone, Debug)]
207218 pub public: bool,
208219 pub points: Vec<Point>,
209220 pub changes: Vec<Change>,
221 /// People invited to take part: they can open changes, revise, discuss and attach evidence.
222 pub contributors: Vec<String>,
210223 pub at: i64,
211224}
212225
231244 pub agents: Vec<Agent>,
232245 pub groups: Vec<Group>,
233246 pub tokens: Vec<Token>,
247 pub invites: Vec<Invite>,
234248 pub projects: Vec<Project>,
235249 pub records: u64,
236250}
296310 pub fn owns(&self, owner: &str, person: &str) -> bool {
297311 owner == person || self.group(owner).is_some_and(|g| g.members.iter().any(|m| m == person))
298312 }
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 }
299321 /// Who a token acts as, if it's valid.
300322 pub fn token_actor(&self, hash: &str) -> Option<Actor> {
301323 let t = self.tokens.iter().find(|t| t.hash == hash && !t.revoked)?;
330352 _ => return Err(format!("agent {agent} can't act for {}", by.person)),
331353 }
332354 }
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 }
333361 match t.as_str() {
334362 "person" => {
335363 let username = str_field(rec, "username")?;
353381 let a = self.agents.iter_mut().find(|a| a.id == id && a.person == by.person).ok_or("no such agent")?;
354382 a.revoked = true;
355383 }
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 }
356416 "token" => {
357417 // Tokens are issued by a person, in person, for themselves or one of their agents.
358418 if by.agent.is_some() {
414474 return Err(format!("project {owner}/{name} exists"));
415475 }
416476 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 });
418478 }
419479 "project.move" => {
420480 // A project changes owner; its history goes with it.
src/store.rsmodified+40 −7
171171 Ok(secret)
172172 }
173173
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
174200 /// Who a token's secret acts as.
175201 pub fn authenticate(&self, secret: &str) -> Option<Actor> {
176202 self.state.token_actor(&token_hash(secret))
228254
229255 /// Open a change as an idea: an intent, no code yet. Its first revision makes it a draft.
230256 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;
236258 self.write(by, Self::rec("change", vec![("owner", owner.into()), ("project", name.into()), ("number", number.into()), ("intent", intent.into()), ("state", "idea".into())]))?;
237259 Ok(number)
238260 }
485507 store.move_project(&anton, "anton", "grove", "letterpress").unwrap();
486508 assert!(store.project("anton", "grove").is_err());
487509 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.
489511 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();
490515 let c = store.open_change(&bea, "letterpress", "grove", "from bea").unwrap();
491516 store.revise(&bea, "letterpress", "grove", c, 1, &[Step { id: "s".into(), snapshot: t, message: "m".into() }], None).unwrap();
492517 store.set_state(&bea, "letterpress", "grove", c, ChangeState::Proposed).unwrap();
505530 let (mut store, _, _) = setup(&dir);
506531 store.add_person("visitor", "Visitor").unwrap();
507532 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.
509542 let c = store.open_change(&visitor, "anton", "grove", "An idea from outside").unwrap();
510543 let t = tree(&store, &[("README", "outside")]);
511544 store.revise(&visitor, "anton", "grove", c, 1, &[Step { id: "s".into(), snapshot: t, message: "m".into() }], None).unwrap();
src/wire.rsmodified+3 −1
4141 ("owner", p.owner.clone().into()),
4242 ("name", p.name.clone().into()),
4343 ("public", p.public.into()),
44 ("contributors", Value::Array(p.contributors.iter().map(|c| Value::from(c.as_str())).collect())),
4445 ("at", p.at.into()),
4546 ("points", Value::Array(p.points.iter().map(point).collect())),
4647 ("changes", Value::Array(p.changes.iter().map(change).collect())),
244245 _ => Vec::new(),
245246 };
246247 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))
248250}
249251
250252/// Objects in transit: each is `length (u32 LE), stored bytes`.

Discussion

claude for antonky opened the change ·
claude for antonky sent r1 ·
claude for antonky marked it proposed ·
antonky landed r1 as @9 ·