2026-09-29 15:44:20 +02:00
//! Accounts the content mandates, made at start.
//!
//! **The first person in.** A site with its own Kanidm starts with nobody
//! on any desk, and every later person is invited from a desk - so the
//! first one has to come from somewhere else. `SEED_ADMIN_EMAIL` names
//! them: at start, portal invites that address into the site's most
//! privileged group and mails them the one-time link, exactly as a desk
//! invite would. From there they invite everyone else themselves.
//!
//! - `SEED_ADMIN_EMAIL` - who; unset, nothing happens.
//! - `SEED_ADMIN_NAME` - their display name; defaults to the address's
//! local part.
//! - `SEED_GROUP` - the group; defaults to the one whose desk may invite
//! into the most groups (the board, on a vel), read from the content.
//!
//! Once, not on every start: a done seed is remembered in the
//! `portal_seed` bucket, keyed by address and group, so a restart or an
//! upgrade never mails a second link. Changing `SEED_ADMIN_EMAIL` or
2026-09-29 21:00:10 +02:00
//! `SEED_GROUP` seeds the new pair.
2026-09-29 15:44:20 +02:00
//!
//! **Everyone the content names as responsible.** A page's
//! `responsible: { name, contact }` is a person answering for that
2026-09-30 08:17:27 +02:00
//! question - unless `group` says it is a group's, in which case the
//! address is an inbox and nobody is made for it. A person gets an
//! account: at start, and again whenever the
2026-09-29 21:00:10 +02:00
//! content changes, each responsible address Kanidm does not already
//! hold is given one, and mailed that they are listed as responsible
//! for asking those questions, with the link to sign in. Signed in,
//! their own pages offer them a way to suggest changes.
//! `RESPONSIBLE_GROUP` names a group they all join - on the vel,
//! `redaktor`, which is also who may sign in to the project Gitea - and
//! people who already had an account are added to it without a mail.
//! A desk is still a desk invite. Each address is onboarded once per
//! group (`portal_seed`).
//!
//! **And no longer.** Someone the content has stopped naming leaves
//! `RESPONSIBLE_GROUP` in the same pass, if portal is who put them
//! there. Their account is theirs and stays.
//!
//! Both run side by side and try again a minute apart, twenty times:
//! Kanidm and gdo may still be coming up alongside portal. Every
//! invite made here is put on file in `portal_invites` like a desk's,
//! so a link that runs out unused shows up where someone can send it
//! again.
2026-09-29 15:44:20 +02:00
#![cfg(feature = "ssr" )]
use std ::collections ::{ BTreeMap , BTreeSet , HashMap };
use crate ::content ::Question ;
pub const SEED_BUCKET : & str = "portal_seed" ;
/// The group whose members may invite into the most groups: a page
/// gated to group G carrying an `invite: { group: X }` alternative
/// means G may bring people into X. Ties go to the name that sorts
/// first, so the choice is stable across starts. `None` when no page
/// invites anyone.
pub fn most_privileged_group ( questions : & HashMap < String , Question > ) -> Option < String > {
let mut reach : BTreeMap <& str , BTreeSet <& str >> = BTreeMap ::new ();
for q in questions . values () {
let Some ( group ) = q . qualifies . as_deref () else { continue };
for alt in & q . alternatives {
if let Some ( invite ) = & alt . invite {
reach . entry ( group ). or_default (). insert ( invite . group . as_str ());
}
}
}
reach
. iter ()
. max_by ( | ( a , x ), ( b , y ) | x . len (). cmp ( & y . len ()). then_with ( || b . cmp ( a )))
. map ( | ( group , _ ) | group . to_string ())
}
/// The bucket key for one seed: stable, and within NATS key rules.
pub fn seed_key ( email : & str , group : & str ) -> String {
let clean = | s : & str | s . trim (). to_lowercase (). chars (). map ( | c | if c . is_ascii_alphanumeric () || c == '-' || c == '_' { c } else { '_' }). collect ::< String > ();
format! ( " {} . {} " , clean ( group ), clean ( email ))
}
/// Everyone the content names as responsible, by address (lowercased,
/// `mailto:` taken off): their name as the first page gives it, and the
2026-09-30 08:17:27 +02:00
/// questions they answer for, in page order. A page a group answers
/// for (`responsible.group`) names nobody: the board's inbox is not a
/// person, and its members have their accounts through the desk.
2026-09-29 15:44:20 +02:00
pub fn responsible_people ( questions : & HashMap < String , Question > ) -> BTreeMap < String , ( String , Vec < String > ) > {
let mut ids : Vec <& String > = questions . keys (). collect ();
ids . sort ();
let mut out : BTreeMap < String , ( String , Vec < String > ) > = BTreeMap ::new ();
for id in ids {
let q = & questions [ id ];
let Some ( r ) = & q . responsible else { continue };
2026-09-30 08:17:27 +02:00
if r . group . as_deref (). is_some_and ( | g | ! g . trim (). is_empty ()) {
continue ;
}
2026-09-29 15:44:20 +02:00
let email = contact_email ( & r . contact );
let Some ( email ) = email else { continue };
let entry = out . entry ( email ). or_insert_with ( || ( r . name . trim (). to_string (), Vec ::new ()));
if ! entry . 1. contains ( & q . name ) {
entry . 1. push ( q . name . clone ());
}
}
out
}
/// A responsible `contact` as an address: `mailto:` off, lowercased;
/// `None` for a phone number or anything else that is not one.
pub fn contact_email ( contact : & str ) -> Option < String > {
let c = contact . trim (). trim_start_matches ( "mailto:" ). trim (). to_lowercase ();
( c . contains ( '@' ) && ! c . contains ( char ::is_whitespace )). then_some ( c )
}
2026-09-29 21:00:10 +02:00
/// A failed attempt is tried again this many times, this far apart:
/// Kanidm and gdo may still be coming up beside portal.
const ATTEMPTS : u32 = 20 ;
const APART : std ::time ::Duration = std ::time ::Duration ::from_secs ( 60 );
/// One pass over the responsible people at a time: a reload may arrive
/// while the pass from the start is still at it, and two at once would
/// both find the same person missing and invite them twice.
static PASS : tokio ::sync ::Mutex < () > = tokio ::sync ::Mutex ::const_new (());
/// The first person in and everyone the content names as responsible,
/// side by side, so neither waits on the other's retries - and the
/// responsible again whenever the content changes, for the life of the
/// process.
2026-09-29 15:44:20 +02:00
pub async fn run ( state : crate ::server ::AppState ) {
2026-09-29 21:00:10 +02:00
// Subscribed before the first pass, so content that changes while
// it runs is not missed.
let reloads = state . nats . subscribe ( crate ::content ::CONTENT_RELOADED_SUBJECT ). await ;
tokio ::join! ( seed_admin ( & state ), responsible_until_done ( & state ), async {
let mut reloads = match reloads {
Ok ( reloads ) => reloads ,
Err ( e ) => {
tracing ::error! ( "could not listen for changed content: {e} - a new responsible person waits for the next start" );
return ;
}
};
use futures ::StreamExt ;
while reloads . next (). await . is_some () {
responsible_until_done ( & state ). await ;
}
});
}
/// Passes over the responsible people until one leaves nobody behind.
/// Each pass reads the content afresh, so what it retries is what the
/// content says by then.
async fn responsible_until_done ( state : & crate ::server ::AppState ) {
for attempt in 1 ..= ATTEMPTS {
let left = {
let _one_at_a_time = PASS . lock (). await ;
responsible ( state ). await
};
if left == 0 {
return ;
}
if attempt == ATTEMPTS {
tracing ::error! ( left , "some responsible people could not be set up, for good - until the content changes or portal starts again" );
return ;
}
tracing ::warn! ( left , attempt , "some responsible people could not be set up, trying again in a minute" );
tokio ::time ::sleep ( APART ). await ;
}
2026-09-29 15:44:20 +02:00
}
async fn seed_bucket ( state : & crate ::server ::AppState ) -> Option < async_nats ::jetstream ::kv ::Store > {
match state . jetstream . get_key_value ( SEED_BUCKET ). await {
Ok ( store ) => Some ( store ),
Err ( _ ) => match state . jetstream . create_key_value ( async_nats ::jetstream ::kv ::Config { bucket : SEED_BUCKET . to_string (), .. Default ::default () }). await {
Ok ( store ) => Some ( store ),
Err ( e ) => {
tracing ::error! ( "the seed bucket could not be made: {e} - making no accounts, so as not to make them twice" );
None
}
},
}
}
2026-09-29 21:00:10 +02:00
fn seed_admin_email () -> Option < String > {
std ::env ::var ( "SEED_ADMIN_EMAIL" ). ok (). map ( | e | e . trim (). to_lowercase ()). filter ( | e | e . contains ( '@' ))
}
/// Who portal put in the responsible group and the content no longer
/// names: their key in the seed bucket, and the account. The first
/// person is never among them - with `SEED_GROUP` the same group, their
/// place there is from the seed, not from a page.
pub fn no_longer_responsible (
on_file : & [( String , crate ::people ::Invited )],
people : & BTreeMap < String , ( String , Vec < String > ) > ,
seed_admin : Option <& str > ,
) -> Vec < ( String , crate ::people ::Invited ) > {
on_file
. iter ()
. filter ( | ( _ , invited ) | {
let email = invited . email . trim (). to_lowercase ();
! people . contains_key ( & email ) && seed_admin != Some ( email . as_str ())
})
. cloned ()
. collect ()
}
/// One pass: an account for everyone the content names, and out of the
/// group for everyone it has stopped naming. Returns how many people
/// it could not finish with.
async fn responsible ( state : & crate ::server ::AppState ) -> usize {
2026-09-29 15:44:20 +02:00
let people = responsible_people ( & state . questions . load ());
2026-09-29 21:00:10 +02:00
let group = std ::env ::var ( "RESPONSIBLE_GROUP" ). ok (). map ( | g | g . trim (). to_string ()). filter ( | g | ! g . is_empty ());
if people . is_empty () && group . is_none () {
return 0 ;
2026-09-29 15:44:20 +02:00
}
2026-09-30 13:28:28 +02:00
let Some ( people_backend ) = crate ::people ::People ::from_env () else {
2026-09-29 21:00:10 +02:00
if ! people . is_empty () {
2026-09-30 13:28:28 +02:00
tracing ::info! ( people = people . len (), "the content names responsible people, but {} - no accounts made" , crate ::people ::People ::NOT_CONFIGURED );
2026-09-29 21:00:10 +02:00
}
return 0 ;
2026-09-29 15:44:20 +02:00
};
2026-09-29 21:00:10 +02:00
let Some ( store ) = seed_bucket ( state ). await else { return 0 };
let mut left = 0 ;
for ( email , ( name , questions )) in & people {
let key = seed_key ( email , group . as_deref (). unwrap_or ( "responsible" ));
2026-09-29 15:44:20 +02:00
if let Ok ( Some ( _ )) = store . get ( & key ). await {
continue ;
}
2026-09-29 21:00:10 +02:00
let name = if name . is_empty () { email . split ( '@' ). next (). unwrap_or ( "" ). to_string () } else { name . clone () };
2026-09-30 13:28:28 +02:00
let who = crate ::memberships ::Identity ::from_contact ( email );
2026-09-29 15:44:20 +02:00
// With a group, one call makes the account if there is none and
// adds them either way; without one, only a missing account is
2026-09-30 13:28:28 +02:00
// made (Kanidm; a membership is nothing without a group). Either
// way a person is mailed only when an account was made for
// them: someone who had one already has their way in.
let made = match ( & group , & people_backend ) {
( Some ( group ), people_backend ) => people_backend . invite ( state , & name , & who , group , "responsible" ). await ,
( None , crate ::people ::People ::Kanidm ( kanidm )) => match kanidm . has_person_with_mail ( email ). await {
2026-09-29 15:44:20 +02:00
Ok ( true ) => {
let _ = store . put ( & key , "existing" . into ()). await ;
continue ;
}
2026-09-29 21:00:10 +02:00
Ok ( false ) => kanidm . account ( & name , email ). await ,
2026-09-29 15:44:20 +02:00
Err ( e ) => Err ( e ),
},
2026-09-30 13:28:28 +02:00
( None , crate ::people ::People ::Memberships ) => continue ,
2026-09-29 15:44:20 +02:00
};
match made {
Ok ( invited ) => {
if let Ok ( bytes ) = serde_json ::to_vec ( & invited ) {
let _ = state . nats . publish ( crate ::people ::INVITED_SUBJECT , bytes . clone (). into ()). await ;
let _ = store . put ( & key , bytes . into ()). await ;
}
if invited . created {
2026-09-29 21:00:10 +02:00
crate ::people ::record ( state , & invited , questions ). await ;
crate ::mail ::on_responsible ( state , & invited , questions ). await ;
2026-09-29 15:44:20 +02:00
}
tracing ::info! ( user = % invited . username , created = invited . created , group = group . as_deref (). unwrap_or ( "" ), "a person the content names as responsible has their account" );
}
2026-09-29 21:00:10 +02:00
Err ( e ) => {
left += 1 ;
tracing ::warn! ( % email , "could not set up a responsible person: {e}" );
}
2026-09-29 15:44:20 +02:00
}
}
2026-09-29 21:00:10 +02:00
// Being responsible ends when the content stops saying so. The
// account stays - it is theirs - but the place in the group, and
// whatever else that group opens, goes with the page. Only what
// portal gave: someone who was in the group before is left there.
let Some ( group ) = group else { return left };
let prefix = seed_key ( "" , & group );
let keys : Vec < String > = {
use futures ::TryStreamExt ;
match store . keys (). await {
Ok ( keys ) => keys . try_collect (). await . unwrap_or_default (),
Err ( _ ) => Vec ::new (),
}
};
let mut on_file = Vec ::new ();
for key in keys . into_iter (). filter ( | k | k . starts_with ( & prefix )) {
let Ok ( Some ( bytes )) = store . get ( & key ). await else { continue };
let Ok ( invited ) = serde_json ::from_slice ::< crate ::people ::Invited > ( & bytes ) else { continue };
on_file . push (( key , invited ));
}
for ( key , invited ) in no_longer_responsible ( & on_file , & people , seed_admin_email (). as_deref ()) {
if ! invited . already_member {
2026-09-30 13:28:28 +02:00
let who = crate ::memberships ::Identity ::from_contact ( & invited . email );
if let Err ( e ) = people_backend . remove ( state , & who , & invited . username , & group , "responsible" ). await {
2026-09-29 21:00:10 +02:00
left += 1 ;
tracing ::warn! ( user = % invited . username , group , "could not take someone no longer responsible out of the group: {e}" );
continue ;
}
}
// Forgotten, so that being named again is being set up again.
let _ = store . purge ( & key ). await ;
tracing ::info! ( user = % invited . username , group , removed = ! invited . already_member , "the content no longer names this person as responsible" );
}
left
2026-09-29 15:44:20 +02:00
}
2026-09-30 13:28:28 +02:00
/// The first person in, once, if `SEED_ADMIN_EMAIL` asks for one -
/// or `SEED_ADMIN_PHONE`, for a site whose provider knows people by
/// their number.
2026-09-29 15:44:20 +02:00
async fn seed_admin ( state : & crate ::server ::AppState ) {
2026-09-30 13:28:28 +02:00
let phone = std ::env ::var ( "SEED_ADMIN_PHONE" ). ok (). map ( | p | p . trim (). to_string ()). filter ( | p | ! p . is_empty ());
let email = std ::env ::var ( "SEED_ADMIN_EMAIL" ). ok (). map ( | e | e . trim (). to_string ()). filter ( | e | e . contains ( '@' ));
let ( Some ( contact ), who ) = (
phone . clone (). or_else ( || email . clone ()),
crate ::memberships ::Identity {
sub : None ,
phone : phone . as_deref (). and_then ( | p | crate ::memberships ::normalize_phone ( p , & std ::env ::var ( "PHONE_COUNTRY" ). unwrap_or_else ( | _ | "+47" . into ()))),
email : email . as_deref (). map ( | e | e . to_lowercase ()),
},
) else {
2026-09-29 15:44:20 +02:00
return ;
};
2026-09-30 13:28:28 +02:00
let email = contact ;
2026-09-29 15:44:20 +02:00
let name = std ::env ::var ( "SEED_ADMIN_NAME" )
. ok ()
. map ( | n | n . trim (). to_string ())
. filter ( | n | ! n . is_empty ())
. unwrap_or_else ( || email . split ( '@' ). next (). unwrap_or ( "admin" ). to_string ());
let group = match std ::env ::var ( "SEED_GROUP" ). ok (). map ( | g | g . trim (). to_string ()). filter ( | g | ! g . is_empty ()) {
Some ( g ) => g ,
None => match most_privileged_group ( & state . questions . load ()) {
Some ( g ) => g ,
None => {
tracing ::warn! ( "SEED_ADMIN_EMAIL is set, but no page invites anyone and SEED_GROUP is unset - nobody to seed into" );
return ;
}
},
};
let key = seed_key ( & email , & group );
let Some ( store ) = seed_bucket ( state ). await else { return };
if let Ok ( Some ( _ )) = store . get ( & key ). await {
tracing ::info! ( group , "the first person was already invited into this group; not again" );
return ;
}
2026-09-30 13:28:28 +02:00
let Some ( people ) = crate ::people ::People ::from_env () else {
tracing ::warn! ( group , "SEED_ADMIN_EMAIL is set, but {} - nobody was seeded" , crate ::people ::People ::NOT_CONFIGURED );
2026-09-29 15:44:20 +02:00
return ;
};
let mut attempt = 0 ;
let invited = loop {
attempt += 1 ;
2026-09-30 13:28:28 +02:00
match people . invite ( state , & name , & who , & group , "seed" ). await {
2026-09-29 15:44:20 +02:00
Ok ( invited ) => break invited ,
2026-09-29 21:00:10 +02:00
Err ( e ) if attempt < ATTEMPTS => {
2026-09-29 15:44:20 +02:00
tracing ::warn! ( group , attempt , "seeding the first person failed, trying again in a minute: {e}" );
2026-09-29 21:00:10 +02:00
tokio ::time ::sleep ( APART ). await ;
2026-09-29 15:44:20 +02:00
}
Err ( e ) => {
tracing ::error! ( group , "seeding the first person failed for good: {e}" );
return ;
}
}
};
if let Ok ( bytes ) = serde_json ::to_vec ( & invited ) {
let _ = state . nats . publish ( crate ::people ::INVITED_SUBJECT , bytes . clone (). into ()). await ;
if let Err ( e ) = store . put ( & key , bytes . into ()). await {
tracing ::error! ( group , "the first person is invited, but it could not be remembered: {e} - a restart would invite them again" );
}
}
2026-09-29 21:00:10 +02:00
if invited . created {
crate ::people ::record ( state , & invited , & []). await ;
}
2026-09-29 15:44:20 +02:00
crate ::mail ::on_invite ( state , & invited ). await ;
tracing ::info! ( group , user = % invited . username , created = invited . created , "the first person is invited" );
}
#[cfg(test)]
mod tests {
use super ::* ;
fn page ( id : & str , qualifies : Option <& str > , invites : & [ & str ]) -> ( String , Question ) {
let alternatives : String = invites
. iter ()
. map ( | g | format! ( " - name: Invite into {g} \n invite: {{ group: {g} }}\n " ))
. collect ();
let yaml = format! ( "name: Q? \n {} alternatives: \n {} " , qualifies . map ( | q | format! ( "qualifies: {q} \n " )). unwrap_or_default (), if alternatives . is_empty () { " - name: A \n " . to_string () } else { alternatives });
let mut q : Question = serde_yaml ::from_str ( & yaml ). unwrap ();
q . id = id . to_string ();
( id . to_string (), q )
}
#[test]
fn the_first_person_goes_where_the_most_invitations_can_be_made () {
let qs : HashMap < String , Question > = [
page ( "/" , None , & []),
page ( "/styret" , Some ( "styret" ), & [ "styret" , "kasserer" , "trafikk" , "komiteer" ]),
page ( "/kasserer" , Some ( "kasserer" ), & [ "kasserer" ]),
page ( "/trafikk" , Some ( "trafikk" ), & [ "trafikk" ]),
]
. into_iter ()
. collect ();
assert_eq! ( most_privileged_group ( & qs ). as_deref (), Some ( "styret" ));
// A tie goes to the name that sorts first, every time.
let tie : HashMap < String , Question > = [ page ( "/b" , Some ( "b" ), & [ "x" ]), page ( "/a" , Some ( "a" ), & [ "y" ])]. into_iter (). collect ();
assert_eq! ( most_privileged_group ( & tie ). as_deref (), Some ( "a" ));
// No invitations anywhere: nobody to seed into without SEED_GROUP.
let none : HashMap < String , Question > = [ page ( "/" , None , & [])]. into_iter (). collect ();
assert! ( most_privileged_group ( & none ). is_none ());
}
2026-09-29 21:00:10 +02:00
fn on_file ( email : & str , already_member : bool ) -> ( String , crate ::people ::Invited ) {
(
seed_key ( email , "redaktor" ),
crate ::people ::Invited {
username : email . split ( '@' ). next (). unwrap (). to_string (),
display_name : String ::new (),
email : email . to_string (),
group : "redaktor" . to_string (),
reset_link : None ,
link_expires_ms : None ,
created : true ,
already_member ,
},
)
}
#[test]
fn whoever_the_content_stopped_naming_leaves_but_never_the_first_person () {
let people : BTreeMap < String , ( String , Vec < String > ) > = [( "kari@vel.no" . to_string (), ( "Kari" . to_string (), vec! [ "Hva?" . to_string ()]))]. into_iter (). collect ();
let filed = vec! [ on_file ( "kari@vel.no" , false ), on_file ( "Ola@vel.no" , false ), on_file ( "leder@vel.no" , false )];
let gone = no_longer_responsible ( & filed , & people , Some ( "leder@vel.no" ));
assert_eq! ( gone . iter (). map ( | ( _ , i ) | i . username . as_str ()). collect ::< Vec < _ >> (), vec! [ "Ola" ]);
assert_eq! ( gone [ 0 ]. 0 , "redaktor.ola_vel_no" );
// Nobody named at all: everyone portal put there leaves.
assert_eq! ( no_longer_responsible ( & filed , & BTreeMap ::new (), None ). len (), 3 );
}
2026-09-29 15:44:20 +02:00
#[test]
fn everyone_named_responsible_is_found_once_by_their_address () {
let mut qs : HashMap < String , Question > = HashMap ::new ();
for ( id , name , contact ) in [( "/" , "Hva gjør vi?" , "mailto:Styret@Vel.no" ), ( "/melde-fra" , "Hva vil du melde?" , "styret@vel.no" ), ( "/b" , "Ringe?" , "+47 900 00 000" ), ( "/c" , "Trafikk?" , "trafikk@vel.no" )] {
let mut q : Question = serde_yaml ::from_str ( & format! ( "name: \" {name} \"\n responsible: {{ name: Styret, contact: \" {contact} \" }}\n alternatives: \n - name: A \n " )). unwrap ();
q . id = id . to_string ();
qs . insert ( id . to_string (), q );
}
let people = responsible_people ( & qs );
assert_eq! ( people . keys (). collect ::< Vec < _ >> (), [ "styret@vel.no" , "trafikk@vel.no" ]);
assert_eq! ( people [ "styret@vel.no" ]. 1 , [ "Hva gjør vi?" , "Hva vil du melde?" ]);
assert_eq! ( contact_email ( "+47 900 00 000" ), None );
2026-09-30 08:17:27 +02:00
// A group's inbox is nobody: its members have the desk.
let mut held : Question = serde_yaml ::from_str ( "name: \" Hva? \"\n responsible: { name: Styret, contact: post@vel.no, group: styret } \n alternatives: \n - name: A \n " ). unwrap ();
held . id = "/d" . into ();
qs . insert ( "/d" . into (), held );
assert! ( ! responsible_people ( & qs ). contains_key ( "post@vel.no" ));
2026-09-29 15:44:20 +02:00
}
#[test]
fn a_seed_is_remembered_by_address_and_group () {
assert_eq! ( seed_key ( " Kari.Lien@Tomtervel.no " , "styret" ), "styret.kari_lien_tomtervel_no" );
assert_ne! ( seed_key ( "kari@x.no" , "styret" ), seed_key ( "kari@x.no" , "kasserer" ));
}
}