src/invites.rs
//! Admin → **Invites**: the decisions behind the pane, host-tested.
//!
//! holger mints, lists and revokes single-use invite links on its passkey door
//! (`/-/auth/passkey/invites*`, an admin, stepped up for a mint); this pane
//! calls that door with the person's session and renders holger's answer. The
//! link is shown ONCE, in the answer to the mint, and is nowhere else — holger
//! keeps only its hash, so the list can say who an invite was for but never
//! show the link again.
//!
//! holger refuses with `{"error": "…"}`; the sentence is read out of it
//! ([`crate::session::refusal_words`]). A stale touch carries `"code":"stepup"` and is
//! answered in place ([`crate::step_up`], A28 M9).
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
use serde::{Deserialize, Serialize};
pub const INVITES_PATH: &str = "/-/auth/passkey/invites";
pub const LIST_PATH: &str = "/-/auth/passkey/invites/list";
pub const REVOKE_PATH: &str = "/-/auth/passkey/invites/revoke";
/// The roles an invite can carry. Admin is not offered: the server refuses to
/// mint one, so a new admin is never one stolen link away.
pub const ROLES: [(&str, &str); 2] = [
("viewer", "Viewer — reads only"),
("operator", "Operator — reads and changes"),
];
/// `POST /auth/passkey/invites` body.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct MintRequest {
pub role: String,
/// The name the invitee will carry; `None` leaves it to them.
#[serde(rename = "display", skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
}
impl MintRequest {
/// A blank name is no name: the invitee then picks their own.
pub fn new(role: &str, user: &str) -> Self {
let user = user.trim();
Self {
role: role.to_owned(),
user: (!user.is_empty()).then(|| user.to_owned()),
}
}
}
/// The answer to a mint — the only place the link ever appears.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct Minted {
pub id: String,
/// holger's secret; the link is built from it ([`invite_url`]).
#[serde(rename = "invite")]
pub secret: String,
/// The link — filled in from the secret once holger has answered.
#[serde(default)]
pub url: String,
#[serde(deserialize_with = "role_in_words")]
pub role: String,
#[serde(default, rename = "display", deserialize_with = "name_or_none")]
pub user: Option<String>,
pub expires: i64,
}
/// Pure: the link the invitee opens — this console's login page, on this origin.
pub fn invite_url(origin: &str, secret: &str) -> String {
format!("{}{}?invite={secret}", origin.trim_end_matches('/'), crate::session::LOGIN_PAGE)
}
fn role_in_words<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
let r = String::deserialize(d)?;
Ok(crate::session::role_word(&r).to_owned())
}
fn name_or_none<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
let n = Option::<String>::deserialize(d)?;
Ok(n.filter(|n| !n.trim().is_empty()))
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct InviteList {
pub invites: Vec<InviteRow>,
}
/// One row of holger's invite list. Times are unix seconds.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct InviteRow {
pub id: String,
#[serde(deserialize_with = "role_in_words")]
pub role: String,
#[serde(default, rename = "display", deserialize_with = "name_or_none")]
pub user: Option<String>,
pub created: i64,
pub expires: i64,
pub created_by: String,
#[serde(default)]
pub used_by: Option<String>,
#[serde(default)]
pub used_at: Option<i64>,
pub revoked: bool,
/// The server's own verdict, taken over a clock this page does not own.
pub open: bool,
}
impl InviteRow {
/// What became of it, in the order the server decides it: a revoked or
/// redeemed invite says so even after its expiry has also passed.
/// The tone is a word (`good`/`warn`/`neutral`); the pane maps it.
pub fn status(&self) -> (String, &'static str) {
if self.revoked {
("revoked".into(), "neutral")
} else if let Some(by) = &self.used_by {
(format!("used by {by}"), "good")
} else if self.open {
("open".into(), "warn")
} else {
("expired".into(), "neutral")
}
}
pub fn for_whom(&self) -> String {
self.user.clone().unwrap_or_else(|| "(their choice)".into())
}
}
/// Newest first — the one just minted is at the top.
pub fn newest_first(mut rows: Vec<InviteRow>) -> Vec<InviteRow> {
rows.sort_by(|a, b| b.created.cmp(&a.created).then_with(|| a.id.cmp(&b.id)));
rows
}
/// Why a mint, list or revoke was refused, as a sentence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refused {
/// The session is admin but its user-verified touch is too old: holger's
/// machine-readable `"code":"stepup"` (A28 M9). [`send`] answers it in place
/// with a fresh touch, so the pane sees it only if holger still refuses after one.
StepUp,
/// A fresh touch was asked for and did not happen; the words say why.
Touch(String),
Other(String),
}
pub fn refused(status: u16, body: &str) -> Refused {
if crate::step_up::is_step_up(status, body) {
return Refused::StepUp;
}
let words = crate::session::refusal_words(status, body);
let body = if words == format!("HTTP {status}") { "" } else { words.as_str() };
match status {
401 => Refused::Other("your session has ended — log in again".into()),
_ if !body.is_empty() => Refused::Other(body.to_owned()),
_ => Refused::Other(format!("HTTP {status}")),
}
}
/// How long until `expires`, in the largest unit that is not zero.
pub fn expires_words(expires: i64, now: i64) -> String {
let left = expires - now;
if left <= 0 {
return "expired".into();
}
if left >= 86_400 {
format!("in {} d", left / 86_400)
} else if left >= 3_600 {
format!("in {} h", left / 3_600)
} else {
format!("in {} min", (left / 60).max(1))
}
}
/// One call to the invite door; holger's step-up refusal is answered in place
/// ([`crate::step_up::answer_in_place`]: a fresh touch, the same call once more).
#[cfg(target_arch = "wasm32")]
async fn send<T: serde::de::DeserializeOwned>(path: &str, body: &serde_json::Value) -> Result<T, Refused> {
crate::step_up::answer_in_place(|| send_once(path, body), |e| *e == Refused::StepUp, crate::step_up::touch, |why| Refused::Touch(crate::step_up::touch_failed_words(&why))).await
}
#[cfg(target_arch = "wasm32")]
async fn send_once<T: serde::de::DeserializeOwned>(path: &str, body: &serde_json::Value) -> Result<T, Refused> {
let (status, text) = crate::session::door(path, Some(body)).await.map_err(Refused::Other)?;
if !(200..300).contains(&status) {
return Err(refused(status, &text));
}
serde_json::from_str(&text).map_err(|e| Refused::Other(format!("holger answered something unreadable: {e}")))
}
#[cfg(target_arch = "wasm32")]
pub async fn list() -> Result<Vec<InviteRow>, Refused> {
send::<InviteList>(LIST_PATH, &serde_json::json!({})).await.map(|l| newest_first(l.invites))
}
#[cfg(target_arch = "wasm32")]
pub async fn mint(body: &MintRequest) -> Result<Minted, Refused> {
let ask = serde_json::to_value(body).map_err(|e| Refused::Other(e.to_string()))?;
let mut m: Minted = send(INVITES_PATH, &ask).await?;
let origin = web_sys::window().and_then(|w| w.location().origin().ok()).unwrap_or_default();
m.url = invite_url(&origin, &m.secret);
Ok(m)
}
#[cfg(target_arch = "wasm32")]
pub async fn revoke(id: &str) -> Result<(), Refused> {
send::<serde_json::Value>(REVOKE_PATH, &serde_json::json!({ "id": id })).await.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
fn row(created: i64) -> InviteRow {
InviteRow {
id: format!("id{created}"),
role: "viewer".into(),
user: None,
created,
expires: created + 3_600,
created_by: "bob".into(),
used_by: None,
used_at: None,
revoked: false,
open: true,
}
}
#[test]
fn a_blank_name_is_left_to_the_invitee() {
assert_eq!(MintRequest::new("viewer", " ").user, None);
assert_eq!(
MintRequest::new("viewer", " anna ").user.as_deref(),
Some("anna")
);
let wire = serde_json::to_string(&MintRequest::new("operator", "")).unwrap();
assert_eq!(wire, r#"{"role":"operator"}"#);
let named = serde_json::to_string(&MintRequest::new("viewer", "anna")).unwrap();
assert_eq!(named, r#"{"role":"viewer","display":"anna"}"#, "holger reads the name as `display`");
}
#[test]
fn admin_is_never_offered() {
assert!(ROLES.iter().all(|(r, _)| *r != "admin"));
}
#[test]
fn revoked_and_used_win_over_expired() {
let mut r = row(0);
r.open = false;
assert_eq!(r.status().0, "expired");
r.used_by = Some("anna".into());
assert_eq!(r.status().0, "used by anna");
r.revoked = true;
assert_eq!(r.status().0, "revoked");
assert_eq!(row(0).status().0, "open");
}
#[test]
fn newest_is_first() {
let ids: Vec<_> = newest_first(vec![row(1), row(3), row(2)])
.into_iter()
.map(|r| r.created)
.collect();
assert_eq!(ids, [3, 2, 1]);
}
/// A28 M9: holger's step-up refusal is recognised by its machine-readable code
/// (`"code":"stepup"`), never by the first words of its sentence.
#[test]
fn the_step_up_refusal_is_recognised_and_others_keep_their_sentence() {
let step = "log in again with your key first: this needs a user-verified session from the last 10 minutes";
assert_eq!(refused(403, &serde_json::json!({ "error": step, "code": "stepup" }).to_string()), Refused::StepUp, "holger's coded refusal");
assert_eq!(refused(403, r#"{"error":"a fresh touch is needed","code":"stepup"}"#), Refused::StepUp, "whatever its words");
assert_eq!(refused(403, &serde_json::json!({ "error": step }).to_string()), Refused::Other(step.into()), "no code, no step-up: the words are shown");
assert_eq!(
refused(403, "this needs an admin passkey"),
Refused::Other("this needs an admin passkey".into())
);
assert_eq!(
refused(400, "that name is reserved"),
Refused::Other("that name is reserved".into())
);
assert_eq!(refused(500, ""), Refused::Other("HTTP 500".into()));
}
#[test]
fn expiry_in_the_largest_unit() {
assert_eq!(expires_words(100, 200), "expired");
assert_eq!(expires_words(230, 200), "in 1 min");
assert_eq!(expires_words(200 + 7_200, 200), "in 2 h");
assert_eq!(expires_words(200 + 3 * 86_400, 200), "in 3 d");
}
#[test]
fn the_wire_rows_parse() {
// holger's own shape: `display` (empty = the invitee's choice), its role words.
let json = r#"{"invites":[{"id":"x","display":"","groups":["holger-readers"],"role":"reader","created":1,"expires":2,
"created_by":"bob","used_by":null,"used_at":null,"revoked":false,"open":true}]}"#;
let l: InviteList = serde_json::from_str(json).unwrap();
assert_eq!(l.invites[0].for_whom(), "(their choice)");
assert_eq!(l.invites[0].role, "viewer");
let m: Minted = serde_json::from_str(r#"{"invite":"s3cret","id":"x","expires":9,"display":"anna","groups":[],"role":"writer"}"#).unwrap();
assert_eq!((m.role.as_str(), m.user.as_deref()), ("operator", Some("anna")));
assert_eq!(invite_url("https://holger.example/", &m.secret), "https://holger.example/login?invite=s3cret");
}
}