chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A login and a gateway's grant name knotless's own space types
.changeset/space-scopes.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# A login and a gateway's grant name knotless's own space types | ||
| ||
`knotless login` asks for `space:dev.knotless.space` and `space:dev.knotless.orgSpace`, each with the collections knotless writes there, in place of every space of every app (`space:*`); `knotless delegate` asks the same of a gateway's grant. A login or grant from before still works, and is asked for again as any older one is. | ||
crates/knotless/src/gateway.rs+6−3
| @@ -36,12 +36,15 @@ pub const AWAIT_DELEGATION: &str = "dev.knotless.getDelegation"; | ||
/// for an organization, its record of being one and its records of whom it | ||
/// invited, its space, where it keeps those of invitations and hidden | ||
/// members and its members write theirs, and who its private repositories' | ||
/// spaces hold, which members and admins change through it. | ||
/// spaces hold, which members and admins change through it. Each space | ||
/// type is named, as the login's are ([`crate::oauth::SCOPE`]). | ||
pub const SCOPE: &str = "atproto repo:dev.knotless.repo repo:dev.knotless.ref \ | ||
repo:dev.knotless.claim repo:dev.knotless.pack repo:dev.knotless.org \ | ||
repo:dev.knotless.orgMember blob:*/* \ | ||
space:*?authority=self&collection=dev.knotless.repo&collection=dev.knotless.ref\ | ||
&collection=dev.knotless.claim&collection=dev.knotless.pack&collection=dev.knotless.orgMember\ | ||
space:dev.knotless.space?authority=self&collection=dev.knotless.repo\ | ||
&collection=dev.knotless.ref&collection=dev.knotless.claim&collection=dev.knotless.pack\ | ||
&collection=dev.knotless.orgMember&manage=create&manage=update \ | ||
space:dev.knotless.orgSpace?authority=self&collection=dev.knotless.orgMember\ | ||
&collection=dev.knotless.orgAcceptance&manage=create&manage=update"; | ||
/// A member joining an organization, which its gateway takes them into. | ||
pub const JOIN_ORG: &str = "dev.knotless.joinOrg"; | ||
crates/knotless/src/oauth.rs+52−6
| @@ -32,10 +32,11 @@ const CALLBACK: &str = "/callback"; | ||
/// What knotless asks for, and nothing more: to write the records it keeps | ||
/// in an account, to upload the blobs that hold its packs and pictures, to call gateways | ||
/// and moderation services as the account (its operator's methods too), and the spaces private | ||
/// repositories live in. A PDS without spaces drops the scope it does not | ||
/// know. The space scope names every space type: naming | ||
/// [`crate::space::SPACE_TYPE`] alone takes its lexicon, which is not | ||
/// published yet. | ||
/// repositories and organizations live in. A PDS without spaces drops the | ||
/// scope it does not know. Each space scope names its type, with the | ||
/// collections that type's lexicon lists ([`space_scope`]): an | ||
/// authorization server resolves the lexicon of each type a scope names, | ||
/// and refuses the login if it cannot. | ||
pub static SCOPE: LazyLock<String> = LazyLock::new(|| { | ||
let mut scope = vec!["atproto".to_string()]; | ||
let written = nsid::ALL.iter().chain(&nsid::FORGE).chain([ | ||
| @@ -100,10 +101,33 @@ pub static SCOPE: LazyLock<String> = LazyLock::new(|| { | ||
crate::account::FORGET, | ||
]; | ||
scope.extend(called.map(|lxm| format!("rpc:{lxm}?aud=*"))); | ||
scope.push("space:*?authority=*&collection=*&manage=create&manage=update&manage=delete".into()); | ||
let manage = "&manage=create&manage=update&manage=delete"; | ||
for lexicon in [SPACE_LEXICON, ORG_SPACE_LEXICON] { | ||
scope.push(space_scope(lexicon, "*", manage)); | ||
} | ||
scope.join(" ") | ||
}); | ||
| ||
/// The lexicon of [`crate::space::SPACE_TYPE`], a private repository's. | ||
pub const SPACE_LEXICON: &str = include_str!("../../../lexicons/dev/knotless/space.json"); | ||
/// The lexicon of [`crate::org::SPACE_TYPE`], an organization's. | ||
pub const ORG_SPACE_LEXICON: &str = include_str!("../../../lexicons/dev/knotless/orgSpace.json"); | ||
| ||
/// The scope for the spaces of the type whose lexicon is `lexicon`, under | ||
/// `authority` (`*`, or `self`), to write each collection it lists, and | ||
/// `manage`, the `&manage=` of it. | ||
pub fn space_scope(lexicon: &str, authority: &str, manage: &str) -> String { | ||
let doc: Value = serde_json::from_str(lexicon).expect("a space lexicon is JSON"); | ||
let listed = doc["defs"]["main"]["collections"].as_array().into_iter(); | ||
let collections: String = listed | ||
.flatten() | ||
.filter_map(Value::as_str) | ||
.map(|c| format!("&collection={c}")) | ||
.collect(); | ||
let id = doc["id"].as_str().unwrap_or_default(); | ||
format!("space:{id}?authority={authority}{collections}{manage}") | ||
} | ||
| ||
/// What a call refused for want of scope says in place of the PDS's | ||
/// words, when the session refused was the caller's own login. | ||
pub const LOGIN_BEHIND: &str = | ||
| @@ -797,6 +821,18 @@ mod tests { | ||
assert!(behind(&session, &SCOPE)); | ||
session.oauth.as_mut().unwrap().scope = Some(SCOPE.clone()); | ||
assert!(!behind(&session, &SCOPE)); | ||
// A login that named every space type is behind one that names | ||
// knotless's alone, and a gateway's grant so too: both ask again. | ||
let broad = SCOPE.split(" space:").next().unwrap().to_string() | ||
+ " space:*?authority=*&collection=*&manage=create&manage=update&manage=delete"; | ||
session.oauth.as_mut().unwrap().scope = Some(broad); | ||
assert!(behind(&session, &SCOPE)); | ||
let granted = gateway::SCOPE.split(" space:").next().unwrap().to_string() | ||
+ " space:*?authority=self&collection=dev.knotless.repo"; | ||
session.oauth.as_mut().unwrap().scope = Some(granted); | ||
assert!(behind(&session, gateway::SCOPE)); | ||
session.oauth.as_mut().unwrap().scope = Some(gateway::SCOPE.into()); | ||
assert!(!behind(&session, gateway::SCOPE)); | ||
} | ||
| ||
#[test] | ||
| @@ -843,7 +879,17 @@ mod tests { | ||
assert!(id.starts_with( | ||
"http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto+" | ||
)); | ||
assert!(SCOPE.contains(" space:*?authority=*&")); | ||
// Only knotless's own space types, each by name. | ||
assert!(!SCOPE.contains("space:*")); | ||
let named = |kind: &str| { | ||
SCOPE | ||
.matches(&format!(" space:{kind}?authority=*&")) | ||
.count() | ||
}; | ||
assert_eq!(named("dev.knotless.space"), 1); | ||
assert_eq!(named("dev.knotless.orgSpace"), 1); | ||
assert!(SCOPE.contains("&collection=dev.knotless.rerun&collection=dev.knotless.orgMember&manage=create&manage=update&manage=delete")); | ||
assert!(SCOPE.ends_with(" space:dev.knotless.orgSpace?authority=*&collection=dev.knotless.orgMember&collection=dev.knotless.orgAcceptance&manage=create&manage=update&manage=delete")); | ||
assert!(SCOPE.contains(" repo:dev.knotless.pull ")); | ||
assert!(SCOPE.contains(" repo:dev.knotless.check ")); | ||
assert!(SCOPE.contains(" repo:dev.knotless.minutes ")); | ||
crates/knotless/src/org.rs+48−167
| @@ -51,11 +51,6 @@ use serde_json::{Value, json}; | ||
/// The type of the space an organization keeps its invitations and hidden | ||
/// members in, keyed [`RKEY`]. | ||
pub const SPACE_TYPE: &str = "dev.knotless.orgSpace"; | ||
/// The type that space was before: the NSID of the organization's public | ||
/// record, whose lexicon is no space, so that no scope can name it. What | ||
/// one of that type holds is moved into the organization's space as its | ||
/// gateway next opens it ([`moved`]), and read from there until then. | ||
pub const OLD_SPACE_TYPE: &str = "dev.knotless.org"; | ||
const RKEY: &str = "self"; | ||
/// The most records one write deletes, as a PDS takes them. | ||
const BATCH: usize = 100; | ||
| @@ -71,17 +66,6 @@ pub fn space_uri(org: &str) -> String { | ||
format!("at://{org}/space/{SPACE_TYPE}/{RKEY}") | ||
} | ||
| ||
/// The space `org` kept them in before, of [`OLD_SPACE_TYPE`]. | ||
pub fn old_space_uri(org: &str) -> String { | ||
format!("at://{org}/space/{OLD_SPACE_TYPE}/{RKEY}") | ||
} | ||
| ||
/// Both: what reads the organization's space reads each, until the old | ||
/// one holds nothing. | ||
pub fn space_uris(org: &str) -> [String; 2] { | ||
[space_uri(org), old_space_uri(org)] | ||
} | ||
| ||
/// What a member may do. | ||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | ||
#[serde(rename_all = "lowercase")] | ||
| @@ -246,11 +230,11 @@ fn inside(ctx: &Ctx, id: &Identity, space: &Space) -> Remote { | ||
} | ||
} | ||
| ||
/// The organization's space `uri`, as `reader` reads it: none when it has | ||
/// none, its PDS has no spaces, or `reader` may not read it. | ||
async fn seen(ctx: &Ctx, org: &Identity, reader: &Session, uri: &str) -> Result<Option<Space>> { | ||
/// The organization's space, as `reader` reads it: none when it has none, | ||
/// its PDS has no spaces, or `reader` may not read it. | ||
async fn seen(ctx: &Ctx, org: &Identity, reader: &Session) -> Result<Option<Space>> { | ||
let host = Xrpc::new(ctx.http.clone(), &org.pds); | ||
let joined = Space::join(ctx, &host, uri, &org.did, reader).await; | ||
let joined = Space::join(ctx, &host, &space_uri(&org.did), &org.did, reader).await; | ||
unless_shut(joined.map(Some)) | ||
} | ||
| ||
| @@ -503,27 +487,20 @@ pub async fn gateway(ctx: &Ctx, id: &Identity) -> Option<String> { | ||
/// public of one it took in is of a member in public; in its space, of a | ||
/// hidden one. | ||
/// | ||
/// Answers too the organization's spaces, as `org` reads them: its space, | ||
/// with what the old one held moved into it first ([`moved`]). | ||
async fn recorded(ctx: &Ctx, id: &Identity, org: &Session) -> Result<(Roster, Spaces)> { | ||
/// Answers too the organization's space, as `org` reads it. | ||
async fn recorded(ctx: &Ctx, id: &Identity, org: &Session) -> Result<(Roster, Option<Space>)> { | ||
let open = public(ctx, id); | ||
// Read at once: an account that is no organization keeps none of the | ||
// others, and what reading them said is set aside. | ||
let [uri, old] = space_uris(&id.did); | ||
let (named, shown, space, old) = tokio::join!( | ||
// Read at once: an account that is no organization keeps neither of the | ||
// other two, and what reading them said is set aside. | ||
let (named, shown, space) = tokio::join!( | ||
open.get_record::<OrgRecord>(nsid::ORG, RKEY), | ||
member_records(&open), | ||
seen(ctx, id, org, &uri), | ||
seen(ctx, id, org, &old), | ||
seen(ctx, id, org) | ||
); | ||
let named = named?.map(|r| r.value); | ||
let (shown, space, old) = match &named { | ||
Some(_) => (shown?, space?, old?), | ||
None => (Vec::new(), None, None), | ||
}; | ||
let space = match &old { | ||
Some(old) => moved(ctx, id, org, old, space).await?, | ||
None => space, | ||
let (shown, space) = match &named { | ||
Some(_) => (shown?, space?), | ||
None => (Vec::new(), None), | ||
}; | ||
let kept = match &space { | ||
Some(space) => member_records(&inside(ctx, id, space)).await?, | ||
| @@ -563,7 +540,7 @@ async fn recorded(ctx: &Ctx, id: &Identity, org: &Session) -> Result<(Roster, Sp | ||
gateway: named.and_then(|o| o.gateway), | ||
entries, | ||
}; | ||
Ok((roster, Spaces { space, old })) | ||
Ok((roster, space)) | ||
} | ||
| ||
/// The organization `org` is, read as itself from its own records alone | ||
| @@ -574,88 +551,6 @@ pub async fn roster_as(ctx: &Ctx, org: &Session) -> Result<Roster> { | ||
Ok(roster) | ||
} | ||
| ||
/// An organization's spaces, as it reads them. | ||
struct Spaces { | ||
/// Where it keeps its invitations and hidden members, if it has it. | ||
space: Option<Space>, | ||
/// The one of [`OLD_SPACE_TYPE`] it kept them in before, if it has it, | ||
/// where hidden members' own records of joining may be still. | ||
old: Option<Space>, | ||
} | ||
| ||
/// Moves what the organization `org` is keeps in its old space into its | ||
/// space, as every change through its gateway does first ([`moved`]): | ||
/// done once it passes, and nothing to do after. | ||
pub async fn move_space(ctx: &Ctx, org: &Session) -> Result<()> { | ||
roster_as(ctx, org).await.map(drop) | ||
} | ||
| ||
/// Moves what the organization `org` keeps in `old`, its space of | ||
/// [`OLD_SPACE_TYPE`], into `space`, its space now, made if it has none: | ||
/// the accounts the old one holds, then its records of those it invited, | ||
/// each written in the new before it goes from the old, so that a reader | ||
/// of both, as each is meanwhile, never misses one. An old one with no | ||
/// such record left has nothing to move, and is left as it is. Answers | ||
/// the space moved into. | ||
/// | ||
/// The hidden members' own records of joining stay where they are, as | ||
/// only they write in their repo there: they count from the old space, | ||
/// which keeps them as members, until each joins again ([`join`]). The old | ||
/// space goes only with the organization ([`delete_as`]). | ||
async fn moved( | ||
ctx: &Ctx, | ||
id: &Identity, | ||
org: &Session, | ||
old: &Space, | ||
space: Option<Space>, | ||
) -> Result<Option<Space>> { | ||
let from = inside(ctx, id, old); | ||
let records = member_records(&from).await?; | ||
if records.is_empty() { | ||
return Ok(space); | ||
} | ||
let xrpc = Xrpc::new(ctx.http.clone(), &id.pds); | ||
let admin = Admin { | ||
xrpc: &xrpc, | ||
session: org, | ||
}; | ||
admin.create_of(SPACE_TYPE, RKEY).await?; | ||
let made = seen(ctx, id, org, &space_uri(&id.did)).await?; | ||
let space = made.context("the organization's space was made, and cannot be read")?; | ||
let there = admin.members(&space.uri).await?; | ||
for m in admin.members(&old.uri).await? { | ||
// One the new space holds is as it was put there since. | ||
if !there.iter().any(|t| t.did == m.did) { | ||
admin | ||
.put_member_as(&space.uri, &m.did, m.read, m.write) | ||
.await?; | ||
} | ||
} | ||
let to = inside(ctx, id, &space); | ||
let kept = member_records(&to).await?; | ||
// One in both was written in the new since: that one counts. | ||
let unkept = records | ||
.iter() | ||
.filter(|r| !kept.iter().any(|k| k.member == r.member)); | ||
let write = |r: &OrgMemberRecord| { | ||
let value = records::typed(nsid::ORG_MEMBER, r); | ||
remote::create(nsid::ORG_MEMBER, &r.member, value) | ||
}; | ||
let writes: Vec<Value> = unkept.map(write).collect(); | ||
let gone = records | ||
.iter() | ||
.map(|r| remote::delete(nsid::ORG_MEMBER, &r.member)); | ||
let gone: Vec<Value> = gone.collect(); | ||
let auth = org.auth(); | ||
for batch in writes.chunks(BATCH) { | ||
to.apply(batch.to_vec(), &auth).await?; | ||
} | ||
for batch in gone.chunks(BATCH) { | ||
from.apply(batch.to_vec(), &auth).await?; | ||
} | ||
Ok(Some(space)) | ||
} | ||
| ||
/// What inviting someone, or changing their role, did. | ||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | ||
#[serde(rename_all = "camelCase")] | ||
| @@ -701,7 +596,7 @@ pub struct Joined { | ||
} | ||
| ||
/// The organization `session` is, read as itself, with the places it keeps | ||
/// its records: its space as it opened, if it had one, and its old one. | ||
/// its records: its space as it opened, if it had one. | ||
struct Own<'a> { | ||
ctx: &'a Ctx, | ||
session: &'a Session, | ||
| @@ -709,13 +604,12 @@ struct Own<'a> { | ||
open: Remote, | ||
roster: Roster, | ||
space: Option<Space>, | ||
old: Option<Space>, | ||
} | ||
| ||
impl<'a> Own<'a> { | ||
async fn open(ctx: &'a Ctx, session: &'a Session) -> Result<Own<'a>> { | ||
let id = identity::resolve(ctx, &session.did).await?; | ||
let (roster, Spaces { space, old }) = recorded(ctx, &id, session).await?; | ||
let (roster, space) = recorded(ctx, &id, session).await?; | ||
let open = public(ctx, &id); | ||
Ok(Own { | ||
ctx, | ||
| @@ -724,7 +618,6 @@ impl<'a> Own<'a> { | ||
open, | ||
roster, | ||
space, | ||
old, | ||
}) | ||
} | ||
| ||
| @@ -748,8 +641,7 @@ impl<'a> Own<'a> { | ||
Err(err) if space::refused(&err) && !space::scope_missing(&err) => Ok(None), | ||
made => { | ||
made?; | ||
let uri = space_uri(&self.id.did); | ||
seen(self.ctx, &self.id, self.session, &uri).await | ||
seen(self.ctx, &self.id, self.session).await | ||
} | ||
} | ||
} | ||
| @@ -919,7 +811,7 @@ pub async fn invite_as( | ||
} | ||
| ||
/// Takes `who` (a DID) out of the organization `org` is, as the | ||
/// organization: out of its records, its spaces, and its private | ||
/// organization: out of its records, its space, and its private | ||
/// repositories' spaces, but for those shared with them on their own. | ||
pub async fn remove_as(ctx: &Ctx, org: &Session, who: &str) -> Result<Removed> { | ||
let own = Own::open(ctx, org).await?; | ||
| @@ -932,7 +824,7 @@ pub async fn remove_as(ctx: &Ctx, org: &Session, who: &str) -> Result<Removed> { | ||
for place in [Some(own.open.clone()), kept].into_iter().flatten() { | ||
own.unname(&place, who).await?; | ||
} | ||
for space in [&own.space, &own.old].into_iter().flatten() { | ||
if let Some(space) = &own.space { | ||
own.admin().remove_member(&space.uri, who).await?; | ||
} | ||
if admitted { | ||
| @@ -965,23 +857,19 @@ pub async fn admit_as( | ||
bail!("{name} has not invited you: ask one of its admins to (`knotless org add`)"); | ||
}; | ||
// Read at once: their join record, wherever they wrote it, and its | ||
// record of them, in public and in its space. One in its old space | ||
// stands until they join again. | ||
// record of them, in public and in its space. | ||
let member = identity::resolve(ctx, who).await?; | ||
let kept = own.space.as_ref().map(|s| inside(ctx, &own.id, s)); | ||
let theirs = own.space.as_ref().map(|s| inside(ctx, &member, s)); | ||
let older = own.old.as_ref().map(|s| inside(ctx, &member, s)); | ||
let shared = public(ctx, &member); | ||
let (open_join, hidden_join, old_join, shown, held) = tokio::join!( | ||
let (open_join, hidden_join, shown, held) = tokio::join!( | ||
joined(&shared, &own.id.did), | ||
OptionFuture::from(theirs.as_ref().map(|t| joined(t, &own.id.did))), | ||
OptionFuture::from(older.as_ref().map(|t| joined(t, &own.id.did))), | ||
member_record(&own.open, who), | ||
OptionFuture::from(kept.as_ref().map(|k| member_record(k, who))), | ||
); | ||
let (open_join, shown) = (open_join?, shown?); | ||
let hidden_join = hidden_join.transpose()?.unwrap_or(false); | ||
let hidden_join = hidden_join || old_join.transpose()?.unwrap_or(false); | ||
let held = held.transpose()?.flatten(); | ||
// Moving the record, they may keep it in both places a moment: the | ||
// one they ask for counts. | ||
| @@ -1028,7 +916,7 @@ pub async fn admit_as( | ||
} | ||
| ||
/// Ends the organization `org` is, as the organization: takes out each | ||
/// member and invitation, then deletes its spaces, and its records in | ||
/// member and invitation, then deletes its space, and its records in | ||
/// public, a batch at a time. | ||
pub async fn delete_as(ctx: &Ctx, org: &Session) -> Result<Vec<Entry>> { | ||
let own = Own::open(ctx, org).await?; | ||
| @@ -1038,7 +926,7 @@ pub async fn delete_as(ctx: &Ctx, org: &Session) -> Result<Vec<Entry>> { | ||
own.reach(&entry.did, false).await?; | ||
} | ||
} | ||
for space in [&own.space, &own.old].into_iter().flatten() { | ||
if let Some(space) = &own.space { | ||
own.admin().delete(&space.uri).await?; | ||
} | ||
let named = member_records(&own.open).await?; | ||
| @@ -1066,13 +954,6 @@ async fn ask<T: serde::de::DeserializeOwned>( | ||
lxm: &str, | ||
body: Value, | ||
) -> Result<T> { | ||
let gateway = acts_through(roster)?; | ||
let (xrpc, auth) = gateway::client(ctx, me, gateway, lxm).await?; | ||
xrpc.procedure(lxm, Some(&body), Some(&auth)).await | ||
} | ||
| ||
/// The gateway the organization `roster` is of acts through, or why none. | ||
fn acts_through(roster: &Roster) -> Result<&str> { | ||
let Some(gateway) = &roster.gateway else { | ||
bail!( | ||
"{} acts through no gateway: whoever holds its login runs `knotless delegate {}`", | ||
| @@ -1080,7 +961,8 @@ fn acts_through(roster: &Roster) -> Result<&str> { | ||
roster.name | ||
); | ||
}; | ||
Ok(gateway) | ||
let (xrpc, auth) = gateway::client(ctx, me, gateway, lxm).await?; | ||
xrpc.procedure(lxm, Some(&body), Some(&auth)).await | ||
} | ||
| ||
/// `org` (a handle or DID; yours when none), as `me` sees it, and with the | ||
| @@ -1226,9 +1108,8 @@ pub async fn delete(ctx: &Ctx) -> Result<(Roster, Vec<Entry>)> { | ||
| ||
/// Joins `org` (a handle or DID) as whoever is logged in, seen by anyone | ||
/// or by its members alone: writes the join record where `visibility` says, | ||
/// taking back one in any other place, and has the organization's gateway | ||
/// take the rest in. A hidden one goes in the organization's space, so | ||
/// joining again moves one kept in its old one ([`OLD_SPACE_TYPE`]). | ||
/// taking back one in the other place, and has the organization's gateway | ||
/// take the rest in. | ||
pub async fn join(ctx: &Ctx, org: &str, visibility: Visibility) -> Result<Joined> { | ||
let me = session::me(ctx).await?; | ||
let mine = identity::resolve(ctx, &me.did).await?; | ||
| @@ -1238,20 +1119,22 @@ pub async fn join(ctx: &Ctx, org: &str, visibility: Visibility) -> Result<Joined | ||
bail!("{} is not an organization", roster.name); | ||
} | ||
let open = public(ctx, &mine); | ||
let [hidden, old] = space_uris(&org_id.did).map(|uri| Remote { | ||
space: Some(Space::owner(&uri, &me)), | ||
let hidden = Remote { | ||
space: Some(Space::owner(&space_uri(&org_id.did), &me)), | ||
..open.clone() | ||
}); | ||
let place = match visibility { | ||
Visibility::Public => &open, | ||
Visibility::Hidden => &hidden, | ||
}; | ||
let (place, other) = match visibility { | ||
Visibility::Public => (&open, &hidden), | ||
Visibility::Hidden => (&hidden, &open), | ||
}; | ||
let record = OrgAcceptanceRecord { | ||
subject: org_id.did.clone(), | ||
created_at: records::now(), | ||
}; | ||
let value = records::typed(nsid::ORG_ACCEPTANCE, &record); | ||
let found = joined(place, &org_id.did).await; | ||
// Both places at once: the other's record, should it be there, goes | ||
// once the gateway took this one in. | ||
let (found, there) = tokio::join!(joined(place, &org_id.did), joined(other, &org_id.did)); | ||
let was = matches!(found, Ok(true)); | ||
let wrote = match found { | ||
Ok(true) => Ok(()), | ||
| @@ -1271,17 +1154,15 @@ pub async fn join(ctx: &Ctx, org: &str, visibility: Visibility) -> Result<Joined | ||
wrote?; | ||
let body = json!({"org": org_id.did, "visibility": visibility}); | ||
let asked: Result<Joined> = ask(ctx, &me, &roster, gateway::JOIN_ORG, body).await; | ||
// Refused, the new record joins nothing, so it goes; taken in, those in | ||
// the other places do, which only then are no longer the membership. | ||
let stale = match &asked { | ||
Ok(_) => [&open, &hidden, &old].map(|p| Some(p).filter(|p| !std::ptr::eq(*p, place))), | ||
Err(_) => [(!was).then_some(place), None, None], | ||
// Refused, the new record joins nothing, so it goes; taken in, the one | ||
// in the other place does, which only then is no longer the membership. | ||
let (stale, place) = match &asked { | ||
Ok(_) => (there.unwrap_or(false), other), | ||
Err(_) => (!was, place), | ||
}; | ||
for place in stale.into_iter().flatten() { | ||
if joined(place, &org_id.did).await.unwrap_or(false) { | ||
let gone = vec![remote::delete(nsid::ORG_ACCEPTANCE, &org_id.did)]; | ||
place.apply(gone, &me.auth()).await?; | ||
} | ||
if stale { | ||
let gone = vec![remote::delete(nsid::ORG_ACCEPTANCE, &org_id.did)]; | ||
place.apply(gone, &me.auth()).await?; | ||
} | ||
asked | ||
} | ||
| @@ -1298,12 +1179,12 @@ pub async fn leave(ctx: &Ctx, org: &str) -> Result<(String, Option<String>)> { | ||
let name = identity::handles(ctx, [org_id.did.as_str()]).await; | ||
let name = name.get(&org_id.did).cloned().unwrap_or(org_id.did.clone()); | ||
let open = public(ctx, &mine); | ||
let [hidden, old] = space_uris(&org_id.did).map(|uri| Remote { | ||
space: Some(Space::owner(&uri, &me)), | ||
let hidden = Remote { | ||
space: Some(Space::owner(&space_uri(&org_id.did), &me)), | ||
..open.clone() | ||
}); | ||
}; | ||
let mut left = false; | ||
for place in [&open, &hidden, &old] { | ||
for place in [&open, &hidden] { | ||
if joined(place, &org_id.did).await.unwrap_or(false) { | ||
let gone = vec![remote::delete(nsid::ORG_ACCEPTANCE, &org_id.did)]; | ||
place.apply(gone, &me.auth()).await?; | ||
crates/knotless-serve/src/index.rs+8−60
| @@ -2765,27 +2765,15 @@ impl Index { | ||
/// public records when empty) name, the first invited first, each with | ||
/// whether they keep a record of joining it there. | ||
pub fn org_roster(&self, org: &str, space: &str) -> rusqlite::Result<Vec<Invitee>> { | ||
self.org_roster_with(org, space, space) | ||
} | ||
| ||
/// [`Index::org_roster`], a record of joining kept in `also` counting | ||
/// too: an organization's old space, for its space, as a hidden member | ||
/// keeps theirs there until they join again. | ||
pub fn org_roster_with( | ||
&self, | ||
org: &str, | ||
space: &str, | ||
also: &str, | ||
) -> rusqlite::Result<Vec<Invitee>> { | ||
let db = self.db.lock().unwrap(); | ||
let mut stmt = db.prepare_cached( | ||
"SELECT m.did, m.role, m.invited_at, m.expires_at, m.joined_at, | ||
EXISTS (SELECT 1 FROM membership j | ||
WHERE j.space IN (m.space, ?3) AND j.did = m.did AND j.org = m.org) | ||
WHERE j.space = m.space AND j.did = m.did AND j.org = m.org) | ||
FROM org_member m WHERE m.space = ?1 AND m.org = ?2 | ||
ORDER BY m.invited_at, m.did", | ||
)?; | ||
let rows = stmt.query_map([space, org, also], invitee)?; | ||
let rows = stmt.query_map([space, org], invitee)?; | ||
rows.collect() | ||
} | ||
| ||
| @@ -2807,46 +2795,18 @@ impl Index { | ||
Ok(found.optional()?.flatten()) | ||
} | ||
| ||
/// The organizations whose public records name `gateway` the one they | ||
/// act through, by DID. | ||
pub fn orgs_through(&self, gateway: &str) -> rusqlite::Result<Vec<String>> { | ||
let db = self.db.lock().unwrap(); | ||
let mut stmt = db.prepare_cached( | ||
"SELECT did FROM org WHERE space = '' AND gateway = ?1 ORDER BY did | ||
-- scans: org, of a row an organization, once as the server starts", | ||
)?; | ||
stmt.query_map([gateway], |row| row.get(0))?.collect() | ||
} | ||
| ||
/// How many records of joining an organization are in an old space of | ||
/// one ([`knotless::org::OLD_SPACE_TYPE`]): hidden members' who have | ||
/// not joined again since it moved. | ||
pub fn old_org_joins(&self) -> rusqlite::Result<u64> { | ||
let db = self.db.lock().unwrap(); | ||
let old = format!("/space/{}/self", knotless::org::OLD_SPACE_TYPE); | ||
db.query_row( | ||
"SELECT count(*) FROM membership WHERE space = 'at://' || org || ?1 | ||
-- scans: membership, of a row a membership, once as the server starts", | ||
[old], | ||
|row| row.get(0), | ||
) | ||
} | ||
| ||
/// The organizations `did` is a member of, by DID: in public, or with | ||
/// `hidden` those whose spaces name them taken in, where they keep a | ||
/// record of joining: its space, or its old one, either. | ||
/// record of joining. | ||
pub fn orgs_of(&self, did: &str, hidden: bool) -> rusqlite::Result<Vec<String>> { | ||
let db = self.db.lock().unwrap(); | ||
let space = |kind: &str| { | ||
format!("CASE WHEN ?2 THEN 'at://' || m.org || '/space/{kind}/self' ELSE '' END") | ||
}; | ||
let spaces = [knotless::org::SPACE_TYPE, knotless::org::OLD_SPACE_TYPE].map(space); | ||
let spaces = spaces.join(", "); | ||
let mut stmt = db.prepare_cached(&format!( | ||
"SELECT DISTINCT m.org FROM org_member m | ||
JOIN membership j ON j.space IN ({spaces}) AND j.did = m.did AND j.org = m.org | ||
WHERE m.did = ?1 AND m.joined_at IS NOT NULL AND m.space IN ({spaces}) | ||
"SELECT m.org FROM org_member m | ||
JOIN membership j ON j.space = m.space AND j.did = m.did AND j.org = m.org | ||
WHERE m.did = ?1 AND m.joined_at IS NOT NULL | ||
AND m.space = CASE WHEN ?2 THEN 'at://' || m.org || '/space/{}/self' ELSE '' END | ||
ORDER BY m.org", | ||
knotless::org::SPACE_TYPE | ||
))?; | ||
stmt.query_map(params![did, hidden], |row| row.get(0))? | ||
.collect() | ||
| @@ -5504,18 +5464,6 @@ mod tests { | ||
.put_from_space(&space, "did:h", nsid::ORG_ACCEPTANCE, "did:o", &joined) | ||
.unwrap(); | ||
assert_eq!(named(&space), [pair("did:h", true)]); | ||
// One kept in its old space counts for its space too, as a hidden | ||
// member's does until they join again. | ||
let old = knotless::org::old_space_uri("did:o"); | ||
index | ||
.delete_from_space(&space, "did:h", nsid::ORG_ACCEPTANCE, "did:o") | ||
.unwrap(); | ||
index | ||
.put_from_space(&old, "did:h", nsid::ORG_ACCEPTANCE, "did:o", &joined) | ||
.unwrap(); | ||
assert_eq!(named(&space), [pair("did:h", false)]); | ||
let roster = index.org_roster_with("did:o", &space, &old).unwrap(); | ||
assert!(roster[0].joined); | ||
assert_eq!(named("").len(), 2); | ||
assert_eq!(index.orgs_of("did:h", true).unwrap(), ["did:o"]); | ||
assert!(index.orgs_of("did:h", false).unwrap().is_empty()); | ||
crates/knotless-serve/src/lib.rs+0−2
| @@ -1171,8 +1171,6 @@ pub async fn run(opts: Options, ctx: Ctx) -> Result<()> { | ||
tokio::spawn(jetstream::watch(app.clone(), url)); | ||
} | ||
tokio::spawn(spaces::follow(app.clone(), SPACES)); | ||
let moving = app.clone(); | ||
tokio::spawn(async move { moving.move_org_spaces().await }); | ||
tokio::spawn(languages::follow(app.clone())); | ||
tokio::spawn(telemetry::summaries(app.clone(), SUMMARY)); | ||
let listener = tokio::net::TcpListener::bind(&opts.listen).await?; | ||
crates/knotless-serve/src/orgs.rs+33−89
| @@ -29,19 +29,10 @@ impl App { | ||
return None; | ||
} | ||
let open = index.org_roster(org, "").unwrap_or_default(); | ||
// Its space and its old one, each a record of joining in the other | ||
// counting too, as while it moves: the one in its space counts. | ||
let [space, old] = org::space_uris(org); | ||
let mut kept = Vec::new(); | ||
for (space, also) in [(&space, &old), (&old, &space)] { | ||
let roster = self.spaces.as_ref(); | ||
let roster = roster.and_then(|i| i.org_roster_with(org, space, also).ok()); | ||
let roster = roster.unwrap_or_default().into_iter(); | ||
let unkept: Vec<Invitee> = roster | ||
.filter(|m| !kept.iter().any(|k: &Invitee| k.did == m.did)) | ||
.collect(); | ||
kept.extend(unkept); | ||
} | ||
let space = org::space_uri(org); | ||
let kept = self.spaces.as_ref(); | ||
let kept = kept.and_then(|i| i.org_roster(org, &space).ok()); | ||
let kept = kept.unwrap_or_default(); | ||
// Moving between the two, a record may be in both a moment. | ||
let mut listed = open.clone(); | ||
let unlisted = kept.iter().filter(|k| !open.iter().any(|m| m.did == k.did)); | ||
| @@ -56,15 +47,12 @@ impl App { | ||
} | ||
| ||
/// Whether this server keeps an index of the organization `org`'s | ||
/// space, or its old one, synced at least once, which alone says who | ||
/// is a hidden member. One with nothing in it never is, and holds no | ||
/// one to say. | ||
/// space, synced at least once, which alone says who is a hidden | ||
/// member. One with nothing in it never is, and holds no one to say. | ||
pub fn org_kept(&self, org: &str) -> bool { | ||
let synced = |uri: &String| { | ||
let synced = self.spaces.as_ref().map(|i| i.space_synced(uri)); | ||
matches!(synced, Some(Ok(true))) | ||
}; | ||
org::space_uris(org).iter().any(synced) | ||
let uri = org::space_uri(org); | ||
let synced = self.spaces.as_ref().map(|i| i.space_synced(&uri)); | ||
matches!(synced, Some(Ok(true))) | ||
} | ||
| ||
/// Where `did` stands in the organization `org`, if it invited them. | ||
| @@ -115,21 +103,19 @@ impl App { | ||
} | ||
} | ||
| ||
/// Keeps an index of the organization `org`'s space, and of its old | ||
/// one, synced as `viewer`, when none is kept: read from the index from | ||
/// then on, as a private repository's is ([`App::keep`]). A viewer who | ||
/// may not read one, or one there is not, leaves none, and is not | ||
/// asked again for a while. | ||
/// Keeps an index of the organization `org`'s space, synced as | ||
/// `viewer`, when none is kept: read from the index from then on, as a | ||
/// private repository's is ([`App::keep`]). A viewer who may not read | ||
/// it leaves none, and is not asked again for a while. | ||
pub async fn keep_org(&self, org: &str, viewer: &str) { | ||
for uri in org::space_uris(org) { | ||
let unkept = self.spaces.as_ref(); | ||
let unkept = unkept.filter(|i| matches!(i.syncer_of(&uri), Ok(None))); | ||
let tried = unkept.filter(|_| self.discovering(&format!("{uri} {viewer}"))); | ||
if let Some(index) = tried | ||
&& self.keep(&uri, viewer).await.is_err() | ||
{ | ||
self.unfollow(index, &uri); | ||
} | ||
let uri = org::space_uri(org); | ||
let unkept = self.spaces.as_ref(); | ||
let unkept = unkept.filter(|i| matches!(i.syncer_of(&uri), Ok(None))); | ||
let tried = unkept.filter(|_| self.discovering(&format!("{uri} {viewer}"))); | ||
if let Some(index) = tried | ||
&& self.keep(&uri, viewer).await.is_err() | ||
{ | ||
self.unfollow(index, &uri); | ||
} | ||
} | ||
| ||
| @@ -191,66 +177,24 @@ impl App { | ||
} | ||
| ||
/// Takes out of the indexes `member`'s record of joining `org` in the | ||
/// places they left, having joined with `visibility`: in public when they | ||
/// moved to its space, else in its spaces, the old one too. One who | ||
/// joined its space again leaves its old one. | ||
/// place they left, having joined with `visibility`: in public when they | ||
/// moved to its space, else in its space. | ||
pub fn unjoined(&self, org: &str, member: &str, visibility: Visibility) { | ||
let [space, old] = org::space_uris(org).map(Some); | ||
let left = match visibility == Visibility::Hidden { | ||
true => [(self.index.as_ref(), None), (self.spaces.as_ref(), old)], | ||
false => [(self.spaces.as_ref(), space), (self.spaces.as_ref(), old)], | ||
let moved_out = visibility == Visibility::Hidden; | ||
let (index, space) = match moved_out { | ||
true => (self.index.as_ref(), None), | ||
false => (self.spaces.as_ref(), Some(org::space_uri(org))), | ||
}; | ||
for (index, space) in left { | ||
let gone = | ||
index.map(|i| i.delete_in(space.as_deref(), member, nsid::ORG_ACCEPTANCE, org)); | ||
let _ = gone; | ||
} | ||
let gone = index.map(|i| i.delete_in(space.as_deref(), member, nsid::ORG_ACCEPTANCE, org)); | ||
let _ = gone; | ||
} | ||
| ||
/// Follows `org`'s space in `index` and syncs it, as the organization, | ||
/// and its old one too while that is followed, which an organization | ||
/// made since never had. | ||
/// Follows `org`'s space in `index` and syncs it, as the organization. | ||
async fn resync(&self, index: &Index, org: &str) { | ||
let uri = org::space_uri(org); | ||
let _ = index.follow_space(&uri, org); | ||
let ctx = crate::login::syncer_ctx(self, org); | ||
let [uri, old] = org::space_uris(org); | ||
let old = Some(old).filter(|old| matches!(index.syncer_of(old), Ok(Some(_)))); | ||
for uri in [Some(uri), old].into_iter().flatten() { | ||
let _ = index.follow_space(&uri, org); | ||
let _ = crate::spaces::sync(&ctx, index, &uri).await; | ||
} | ||
} | ||
| ||
/// Moves each organization this gateway acts for out of its old space | ||
/// ([`org::move_space`]), as it starts, alone among changes to it, and | ||
/// writes what moved into the indexes; then logs how many it moved and | ||
/// how many it could not, which it tries again as it next starts, or | ||
/// as one is next changed, and how many hidden members' records of | ||
/// joining are in old spaces still. None it could not, it is done. | ||
pub async fn move_org_spaces(&self) { | ||
let (Some(gateway), Some(index)) = (self.gateway.as_ref(), self.index.as_ref()) else { | ||
return; | ||
}; | ||
let orgs = index.orgs_through(&gateway.did).unwrap_or_default(); | ||
let (mut moved, mut failed) = (0, 0); | ||
for org in orgs { | ||
let lock = self.org_lock(&org); | ||
let _held = lock.lock().await; | ||
let done = async { | ||
let id = knotless::identity::resolve(&self.ctx, &org).await?; | ||
let session = knotless::session::ensure(&self.ctx, &id).await?; | ||
org::move_space(&self.ctx, &session).await | ||
}; | ||
let done = done.await; | ||
crate::telemetry::org_space_moved(&org, &done); | ||
moved += u64::from(done.is_ok()); | ||
failed += u64::from(done.is_err()); | ||
self.reindex_org(&org, &org).await; | ||
} | ||
let spaces = self.spaces.as_ref(); | ||
let left = spaces | ||
.and_then(|i| i.old_org_joins().ok()) | ||
.unwrap_or_default(); | ||
crate::telemetry::org_spaces_moved(moved, failed, left); | ||
let _ = crate::spaces::sync(&ctx, index, &uri).await; | ||
} | ||
} | ||
| ||
crates/knotless-serve/src/spaces.rs+1−5
| @@ -227,11 +227,7 @@ async fn from_authority( | ||
let aud = format!("{}{}", did.unwrap_or_default(), space::APPVIEW); | ||
let caller = crate::gateway::caller_for(app, headers, &aud, lxm).await?; | ||
let theirs = space.starts_with(&space::uri(&caller, "")); | ||
if !theirs | ||
&& !knotless::org::space_uris(&caller) | ||
.iter() | ||
.any(|uri| uri == space) | ||
{ | ||
if !theirs && space != knotless::org::space_uri(&caller) { | ||
let why = "only a private repository's authority tells of its space"; | ||
return Err(AppError(StatusCode::FORBIDDEN, why.into())); | ||
} | ||
crates/knotless-serve/src/telemetry.rs+0−33
| @@ -325,39 +325,6 @@ pub fn labeler<T>(did: &str, done: anyhow::Result<T>) { | ||
} | ||
} | ||
| ||
/// Logs, at warn, why the organization `org` could not be moved out of | ||
/// its old space as this server started, if it could not: it is tried | ||
/// again as it is next changed, and as this server next starts. | ||
pub fn org_space_moved(org: &str, done: &anyhow::Result<()>) { | ||
if let Err(err) = done { | ||
let fields = Fields { | ||
did: Some(org), | ||
error: Some(format!("{err:#}")), | ||
..Fields::default() | ||
}; | ||
telemetry::note(Level::WARN, "org space not moved", &fields); | ||
} | ||
} | ||
| ||
/// Logs, at info, how moving organizations out of their old spaces went | ||
/// as this server started: how many it moved, or found moved (`count`), | ||
/// and how many it could not (`failed`); none failed, their part is done. | ||
/// Then how many hidden members' records of joining are in old spaces | ||
/// still (`count`), each moved as its member joins again. | ||
pub fn org_spaces_moved(moved: u64, failed: u64, joins: u64) { | ||
let fields = Fields { | ||
count: Some(moved), | ||
failed: Some(failed), | ||
..Fields::default() | ||
}; | ||
telemetry::note(Level::INFO, "org spaces moved", &fields); | ||
let fields = Fields { | ||
count: Some(joins), | ||
..Fields::default() | ||
}; | ||
telemetry::note(Level::INFO, "org joins in old spaces", &fields); | ||
} | ||
| ||
/// Logs that the index was opened at startup, taking `took`. | ||
pub fn opened(index: Option<&crate::index::Index>, took: Duration) { | ||
if let Some(index) = index { | ||
crates/knotless-serve/tests/gateway/orgs.rs+0−100
| @@ -504,103 +504,3 @@ fn private_invitations_heard_of_from_clients() { | ||
let said = heard(); | ||
assert!(said.contains(o) && said.contains("as member"), "{said}"); | ||
} | ||
| ||
/// An organization kept as knotless kept one before: its invitations and | ||
/// hidden members in a space of the old type, with a hidden member's | ||
/// record of joining there. As the gateway starts, it moves the | ||
/// organization's part into its space, each member standing where they | ||
/// stood; a member's own record moves as they join again; and moving | ||
/// again moves nothing. | ||
#[test] | ||
fn organizations_move_out_of_old_spaces() { | ||
let (gw, [org, member, invitee, other]) = served(); | ||
if !net().spaces { | ||
return; | ||
} | ||
let (o, m, i) = ( | ||
org.account.handle.as_str(), | ||
member.account.handle.as_str(), | ||
invitee.account.handle.as_str(), | ||
); | ||
ok(&org, &["delegate", o, &gw.did]); | ||
let did = org.account.did.as_str(); | ||
let old = knotless::org::old_space_uri(did); | ||
let policy = json!({"$type": "com.atproto.memberspace.policy"}); | ||
let made = json!({"spaceType": "dev.knotless.org", "skey": "self", "policy": policy.clone()}); | ||
org.call("com.atproto.space.createSpace", made); | ||
for (who, read) in [(&member, true), (&invitee, false)] { | ||
let put = json!({"space": old, "did": who.account.did, "read": read, "write": true}); | ||
org.call("com.atproto.memberspace.putMember", put); | ||
} | ||
let at = "2026-10-30T00:00:00.000Z"; | ||
let named = json!({"$type": "dev.knotless.org", "gateway": gw.did, "createdAt": at}); | ||
org.put_record("dev.knotless.org", "self", named.clone()); | ||
let hidden = json!({"$type": "dev.knotless.orgMember", "member": member.account.did, | ||
"roles": [{"role": "member"}], "invitedAt": at, "joinedAt": at}); | ||
org.put_space_record(&old, "dev.knotless.orgMember", &member.account.did, hidden); | ||
let asked = json!({"$type": "dev.knotless.orgMember", "member": invitee.account.did, | ||
"roles": [{"role": "member"}], "invitedAt": at, "expiresAt": "2099-01-01T00:00:00.000Z"}); | ||
org.put_space_record( | ||
&old, | ||
"dev.knotless.orgMember", | ||
&invitee.account.did, | ||
asked.clone(), | ||
); | ||
// A move cut off part way: the new space made, holding the invitee | ||
// and its record of them, which counts over the old one's. | ||
let space = knotless::org::space_uri(did); | ||
let made = json!({"spaceType": "dev.knotless.orgSpace", "skey": "self", "policy": policy}); | ||
org.call("com.atproto.space.createSpace", made); | ||
let put = json!({"space": space, "did": invitee.account.did, "read": false, "write": true}); | ||
org.call("com.atproto.memberspace.putMember", put); | ||
org.put_space_record( | ||
&space, | ||
"dev.knotless.orgMember", | ||
&invitee.account.did, | ||
asked, | ||
); | ||
let joined = json!({"$type": "dev.knotless.orgAcceptance", "subject": did, "createdAt": at}); | ||
member.put_space_record(&old, "dev.knotless.orgAcceptance", did, joined); | ||
// One that names this gateway and never delegated to it is not moved. | ||
other.put_record("dev.knotless.org", "self", named); | ||
reindexed(&gw, &other); | ||
// Followed, as a server kept it before. | ||
let index = gw.app.spaces.as_ref().unwrap(); | ||
index.follow_space(&old, did).unwrap(); | ||
reindexed(&gw, &org); | ||
let members = || ok(&org, &["org", "members"]); | ||
let stood = [ | ||
format!("{m}\tmember\thidden"), | ||
format!("{i}\tmember\tinvited"), | ||
]; | ||
let stands = |shown: &str| stood.iter().all(|s| shown.contains(s.as_str())); | ||
let shown = members(); | ||
assert!(stands(&shown), "{shown}"); | ||
| ||
gw._rt.block_on(gw.app.move_org_spaces()); | ||
let roster = |uri: &str| index.org_roster(did, uri).unwrap().len(); | ||
assert_eq!((roster(&space), roster(&old)), (2, 0)); | ||
let shown = members(); | ||
assert!(stands(&shown), "{shown}"); | ||
assert_eq!(index.old_org_joins().unwrap(), 1); | ||
// Moved, it moves nothing. | ||
gw._rt.block_on(gw.app.move_org_spaces()); | ||
assert_eq!((roster(&space), roster(&old)), (2, 0)); | ||
| ||
// Joining again, a hidden member's record of it moves too; an | ||
// invitee joins in its space. | ||
for who in [&member, &invitee] { | ||
assert!(ok(who, &["org", "join", o, "--hidden"]).starts_with("You're a member")); | ||
} | ||
reindexed(&gw, &org); | ||
assert_eq!(index.old_org_joins().unwrap(), 0); | ||
let shown = members(); | ||
assert!(shown.contains(&format!("{i}\tmember\thidden")), "{shown}"); | ||
assert!(shown.contains(&stood[0]), "{shown}"); | ||
| ||
// Ended, it takes both spaces with it. | ||
ok(&org, &["org", "delete", "--yes"]); | ||
reindexed(&gw, &org); | ||
let how = "`knotless org add <handle>` invites someone, and makes it one"; | ||
assert_eq!(members(), format!("{o} is not an organization: {how}.")); | ||
} | ||