Files
kvitt/server/crates/kvitt-server/src/api/invites.rs
T
jacobh2 4b30888dc2 Server overhaul: invite creation and CLI plumbing shared
invites::create_partner_invite is the one partner-invite path of E-12 and invite-partner; E-3's user creation moves
into invited_user; invite kinds parse through rows::token; app_version_code comes from auth. cli::render_invite
replaces the two identical render functions and cli::with_database the open/run/close block of four subcommands.
Same output, exit codes and errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 22:43:54 +02:00

532 lines
18 KiB
Rust

//! Invites: E-3 accept (all three kinds), E-12 create, E-13 list, E-14 cancel (SPEC §8.3, FR-2), plus the code and
//! token generation and the invite creation shared with the `bootstrap`, `pair-device` and `invite-partner`
//! subcommands.
//!
//! Codes are 12 Crockford base32 characters from the OS CSPRNG, stored only as the SHA-256 hex of the normalised
//! code, single-use, valid for 72 hours, cancellable. Every failure of a code is `invite_invalid` without saying why
//! (AC-2.3). Tokens are `kvt_` + base64url(32 random bytes), stored only as their SHA-256 hex (SPEC §8.2).
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use base64::Engine as _;
use chrono::{DateTime, Duration, Utc};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use sqlx::SqliteConnection;
use uuid::Uuid;
use crate::AppState;
use crate::api::{ApiJson, IdPath, JsonBody, rows, validate};
use crate::auth::{AdminDevice, TOKEN_PREFIX, app_version_code, app_version_name, sha256_hex};
use crate::db;
use crate::error::{AppError, ErrorCode};
use crate::openapi::responses::{BadRequest, Conflict, Forbidden, NotFound, Unprocessable};
use crate::time::{format_ts, parse_ts};
use crate::wire::{
AcceptInviteRequest, AcceptInviteResponse, CreateInviteKind, CreateInviteRequest, Invite,
InviteKind, InviteList, Role, UuidSchema,
};
/// The Crockford base32 alphabet of invite codes (SPEC §8.3).
pub const CODE_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// Characters in an invite code.
pub const CODE_LEN: usize = 12;
/// How long an invite is valid (AC-2.3).
pub const INVITE_LIFETIME: Duration = Duration::hours(72);
/// Most users a household has (SPEC §14.1).
pub const MAX_USERS: usize = 2;
/// Fills `buf` from the OS CSPRNG.
fn random_bytes(buf: &mut [u8]) -> Result<(), AppError> {
getrandom::fill(buf).map_err(|e| AppError::internal(format!("OS random source failed: {e}")))
}
/// A new invite code: 12 characters of the alphabet (60 random bits), without hyphens.
pub fn generate_code() -> Result<String, AppError> {
let mut bytes = [0u8; 8];
random_bytes(&mut bytes)?;
let bits = u64::from_le_bytes(bytes);
Ok((0..CODE_LEN)
.map(|i| char::from(CODE_ALPHABET[usize::try_from((bits >> (5 * i)) & 31).unwrap_or(0)]))
.collect())
}
/// Normalises a typed or linked code (SPEC §8.3): uppercase, `-` and spaces removed, `O` → `0`, `I`/`L` → `1`.
/// `None` unless the result is exactly 12 alphabet characters.
pub fn normalise_code(input: &str) -> Option<String> {
let code: String = input
.chars()
.filter(|c| *c != '-' && *c != ' ')
.flat_map(char::to_uppercase)
.map(|c| match c {
'O' => '0',
'I' | 'L' => '1',
other => other,
})
.collect();
(code.len() == CODE_LEN && code.bytes().all(|b| CODE_ALPHABET.contains(&b))).then_some(code)
}
/// `XXXX-XXXX-XXXX` (SPEC §8.3 display form).
pub fn display_code(code: &str) -> String {
let chars: Vec<char> = code.chars().collect();
chars
.chunks(4)
.map(|c| c.iter().collect::<String>())
.collect::<Vec<_>>()
.join("-")
}
/// A new device token: `kvt_` + base64url without padding of 32 CSPRNG bytes (47 characters, SPEC §8.2).
pub fn generate_token() -> Result<String, AppError> {
let mut bytes = [0u8; 32];
random_bytes(&mut bytes)?;
Ok(format!(
"{TOKEN_PREFIX}{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
))
}
/// Query parameter of the landing link and of `kvitt://invite` that names the inviting admin (AC-2.6, S-1 help line).
pub const FROM_PARAM: &str = "from";
/// `<public_url>/i/<code>`, with `?from=<percent-encoded admin display name>` when `from` is given (E-12).
pub fn invite_link(public_url: &str, code: &str, from: Option<&str>) -> String {
match from {
Some(name) => format!(
"{public_url}/i/{code}?{FROM_PARAM}={}",
utf8_percent_encode(name, NON_ALPHANUMERIC)
),
None => format!("{public_url}/i/{code}"),
}
}
/// A freshly created invite with its plain code (shown once).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewInvite {
pub id: Uuid,
pub kind: InviteKind,
pub target_user_id: Option<Uuid>,
pub code: String,
pub created_at: String,
pub expires_at: String,
}
impl NewInvite {
/// The wire form with `code` and `link` (E-12 response); `from` is the inviting admin's display name.
pub fn wire(&self, public_url: &str, from: Option<&str>) -> Invite {
Invite {
id: self.id,
kind: self.kind,
target_user_id: self.target_user_id,
code: Some(self.code.clone()),
link: Some(invite_link(public_url, &self.code, from)),
created_at: self.created_at.clone(),
expires_at: self.expires_at.clone(),
}
}
}
/// Cancels every pending invite of `kind` (not accepted, not cancelled, not expired).
pub async fn cancel_pending(
conn: &mut SqliteConnection,
kind: InviteKind,
now: DateTime<Utc>,
) -> Result<(), AppError> {
sqlx::query(
"UPDATE invites SET cancelled_at = ?1 WHERE kind = ?2 AND accepted_at IS NULL AND cancelled_at IS NULL \
AND expires_at > ?1",
)
.bind(format_ts(now))
.bind(rows::token_of(&kind)?)
.execute(&mut *conn)
.await?;
Ok(())
}
/// Creates a `partner` invite by `admin_id` inside the caller's write transaction, as E-12 and `invite-partner` do:
/// `household_full` once two users exist, otherwise any pending partner invite is cancelled first (AC-2.1).
pub async fn create_partner_invite(
conn: &mut SqliteConnection,
admin_id: Uuid,
now: DateTime<Utc>,
) -> Result<NewInvite, AppError> {
if user_count(conn).await? >= MAX_USERS {
return Err(household_full());
}
cancel_pending(conn, InviteKind::Partner, now).await?;
insert_invite(conn, InviteKind::Partner, None, Some(admin_id), now).await
}
/// Inserts a new invite inside the caller's write transaction.
pub async fn insert_invite(
conn: &mut SqliteConnection,
kind: InviteKind,
target_user_id: Option<Uuid>,
created_by_user_id: Option<Uuid>,
now: DateTime<Utc>,
) -> Result<NewInvite, AppError> {
let code = generate_code()?;
let invite = NewInvite {
id: Uuid::now_v7(),
kind,
target_user_id,
created_at: format_ts(now),
expires_at: format_ts(now + INVITE_LIFETIME),
code,
};
sqlx::query(
"INSERT INTO invites (id, kind, code_hash, target_user_id, created_by_user_id, created_at, expires_at) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(invite.id.to_string())
.bind(rows::token_of(&kind)?)
.bind(sha256_hex(&invite.code))
.bind(target_user_id.map(|id| id.to_string()))
.bind(created_by_user_id.map(|id| id.to_string()))
.bind(&invite.created_at)
.bind(&invite.expires_at)
.execute(&mut *conn)
.await?;
Ok(invite)
}
fn invite_invalid() -> AppError {
AppError::new(
ErrorCode::InviteInvalid,
"the invite code is unknown, expired, used or cancelled",
)
}
fn household_full() -> AppError {
AppError::new(
ErrorCode::HouseholdFull,
"the household already has two users",
)
}
async fn user_count(conn: &mut SqliteConnection) -> Result<usize, AppError> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
.fetch_one(&mut *conn)
.await?;
usize::try_from(n).map_err(AppError::internal)
}
#[derive(sqlx::FromRow)]
struct InviteRow {
id: String,
kind: String,
target_user_id: Option<String>,
expires_at: String,
accepted_at: Option<String>,
cancelled_at: Option<String>,
}
/// The user an accepted invite pairs a device for: the target user of a `device` invite, or a new user for
/// `admin_setup` (the admin, only while no user exists) and `partner` (the member, while fewer than two exist).
async fn invited_user(
conn: &mut SqliteConnection,
kind: InviteKind,
row: &InviteRow,
display_name: Option<&str>,
now_ts: &str,
) -> Result<Uuid, AppError> {
if kind == InviteKind::Device {
let target = row
.target_user_id
.as_deref()
.ok_or_else(|| AppError::internal("device invite without target user"))?;
return rows::uuid(target);
}
let display_name = display_name.ok_or_else(|| {
AppError::invalid_request("display_name is required for this invite")
.with_field("display_name")
})?;
let display_name = validate::text("display_name", display_name, 1, validate::DISPLAY_NAME_MAX)?;
let users = user_count(conn).await?;
let role = if kind == InviteKind::AdminSetup {
if users > 0 {
// A household exists already: this setup code can no longer be used.
return Err(invite_invalid());
}
Role::Admin
} else {
if users >= MAX_USERS {
return Err(household_full());
}
Role::Member
};
let user_id = Uuid::now_v7();
let version = db::next_server_version(conn).await?;
sqlx::query(
"INSERT INTO users (id, role, display_name, picture_version, created_at, updated_at, server_version) \
VALUES (?, ?, ?, NULL, ?, ?, ?)",
)
.bind(user_id.to_string())
.bind(role.as_str())
.bind(&display_name)
.bind(now_ts)
.bind(now_ts)
.bind(version)
.execute(&mut *conn)
.await?;
Ok(user_id)
}
#[utoipa::path(
post,
path = "/invites/accept",
operation_id = "acceptInvite",
summary = "E-3 Pair a device with an invite code (all three invite kinds)",
security(()),
request_body(content = AcceptInviteRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 200, description = "Paired. The token is shown only here.", body = AcceptInviteResponse, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 409, response = Conflict),
(status = 422, response = Unprocessable),
)
)]
pub async fn accept_invite(
State(state): State<AppState>,
headers: HeaderMap,
ApiJson(body): ApiJson<AcceptInviteRequest>,
) -> Result<JsonBody<AcceptInviteResponse>, AppError> {
let code = normalise_code(&body.code).ok_or_else(invite_invalid)?;
let now = state.clock.now();
let mut tx = db::begin_immediate(&state.db).await?;
let row = sqlx::query_as::<_, InviteRow>(
"SELECT id, kind, target_user_id, expires_at, accepted_at, cancelled_at FROM invites WHERE code_hash = ?",
)
.bind(sha256_hex(&code))
.fetch_optional(&mut *tx)
.await?
.ok_or_else(invite_invalid)?;
let expired = parse_ts(&row.expires_at).is_none_or(|t| t <= now);
if row.accepted_at.is_some() || row.cancelled_at.is_some() || expired {
return Err(invite_invalid());
}
let kind: InviteKind = rows::token(&row.kind)?;
let device_name = validate::text(
"device_name",
&body.device_name,
1,
validate::DEVICE_NAME_MAX,
)?;
let now_ts = format_ts(now);
let user_id = invited_user(&mut tx, kind, &row, body.display_name.as_deref(), &now_ts).await?;
let token = generate_token()?;
let device_id = Uuid::now_v7();
sqlx::query(
"INSERT INTO devices (id, user_id, name, token_hash, fcm_token, app_version_code, app_version_name, \
created_at, last_seen_at) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?)",
)
.bind(device_id.to_string())
.bind(user_id.to_string())
.bind(&device_name)
.bind(sha256_hex(&token))
.bind(app_version_code(&headers))
.bind(app_version_name(&headers))
.bind(&now_ts)
.bind(&now_ts)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE invites SET accepted_at = ?, accepted_device_id = ? WHERE id = ?")
.bind(&now_ts)
.bind(device_id.to_string())
.bind(&row.id)
.execute(&mut *tx)
.await?;
let user = rows::user(&mut tx, user_id)
.await?
.ok_or_else(|| AppError::internal("invite target user is missing"))?;
let device = rows::device(&mut tx, device_id, device_id)
.await?
.ok_or_else(|| AppError::internal("new device vanished"))?;
let household = rows::household(&mut tx).await?;
tx.commit().await?;
tracing::info!(device_id = %device_id, invite_kind = row.kind, "device paired");
Ok(JsonBody(AcceptInviteResponse {
token,
invite_kind: kind,
user,
device,
household,
}))
}
#[utoipa::path(
post,
path = "/invites",
operation_id = "createInvite",
summary = "E-12 Create a partner or device invite (admin)",
description = "Creating a `partner` invite cancels any pending one (AC-2.1).",
request_body(content = CreateInviteRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 201, description = "The invite with `code` (12 characters, no hyphens) and `link` (`<public_url>/i/<code>?from=<admin display name, percent-encoded>`).", body = Invite, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 403, response = Forbidden),
(status = 404, response = NotFound),
(status = 409, response = Conflict),
)
)]
pub async fn create_invite(
AdminDevice(admin): AdminDevice,
State(state): State<AppState>,
ApiJson(body): ApiJson<CreateInviteRequest>,
) -> Result<(StatusCode, JsonBody<Invite>), AppError> {
let now = state.clock.now();
let mut tx = db::begin_immediate(&state.db).await?;
let invite = match body.kind {
CreateInviteKind::Partner => {
if body.target_user_id.is_some() {
return Err(AppError::invalid_request(
"target_user_id must be null for a partner invite",
)
.with_field("target_user_id"));
}
create_partner_invite(&mut tx, admin.user_id, now).await?
}
CreateInviteKind::Device => {
let target = body.target_user_id.ok_or_else(|| {
AppError::invalid_request("target_user_id is required for a device invite")
.with_field("target_user_id")
})?;
rows::user(&mut tx, target)
.await?
.ok_or_else(AppError::not_found)?;
insert_invite(
&mut tx,
InviteKind::Device,
Some(target),
Some(admin.user_id),
now,
)
.await?
}
};
// The link names the admin so S-1 can say whom the code came from (AC-2.6).
let from = rows::user(&mut tx, admin.user_id)
.await?
.map(|user| user.display_name);
tx.commit().await?;
Ok((
StatusCode::CREATED,
JsonBody(invite.wire(&state.config.public_url, from.as_deref())),
))
}
#[utoipa::path(
get,
path = "/invites",
operation_id = "listInvites",
summary = "E-13 List pending invites (admin)",
responses(
(status = 200, description = "Pending invites only (not accepted, not cancelled, not expired), `code` and `link` null.", body = InviteList, content_type = "application/json; charset=utf-8"),
(status = 403, response = Forbidden),
)
)]
pub async fn list_invites(
_admin: AdminDevice,
State(state): State<AppState>,
) -> Result<JsonBody<InviteList>, AppError> {
let rows: Vec<(String, String, Option<String>, String, String)> = sqlx::query_as(
"SELECT id, kind, target_user_id, created_at, expires_at FROM invites \
WHERE accepted_at IS NULL AND cancelled_at IS NULL AND expires_at > ? ORDER BY created_at, id",
)
.bind(state.clock.now_ts())
.fetch_all(&state.db)
.await?;
let invites = rows
.into_iter()
.map(|(id, kind, target, created_at, expires_at)| {
Ok(Invite {
id: rows::uuid(&id)?,
kind: rows::token(&kind)?,
target_user_id: target.as_deref().map(rows::uuid).transpose()?,
code: None,
link: None,
created_at,
expires_at,
})
})
.collect::<Result<_, AppError>>()?;
Ok(JsonBody(InviteList { invites }))
}
#[utoipa::path(
delete,
path = "/invites/{invite_id}",
operation_id = "cancelInvite",
summary = "E-14 Cancel an invite (admin)",
description = "Idempotent for an already cancelled invite.",
params(
("invite_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
responses(
(status = 204, description = "Cancelled."),
(status = 403, response = Forbidden),
(status = 404, response = NotFound),
)
)]
pub async fn cancel_invite(
_admin: AdminDevice,
State(state): State<AppState>,
IdPath(invite_id): IdPath,
) -> Result<StatusCode, AppError> {
let mut tx = db::begin_immediate(&state.db).await?;
let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM invites WHERE id = ?)")
.bind(invite_id.to_string())
.fetch_one(&mut *tx)
.await?;
if !exists {
return Err(AppError::not_found());
}
sqlx::query(
"UPDATE invites SET cancelled_at = ? WHERE id = ? AND accepted_at IS NULL AND cancelled_at IS NULL",
)
.bind(state.clock.now_ts())
.bind(invite_id.to_string())
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codes_have_the_alphabet_and_length() {
for _ in 0..100 {
let code = generate_code().unwrap();
assert_eq!(normalise_code(&code).as_deref(), Some(code.as_str()));
}
}
#[test]
fn normalisation() {
assert_eq!(
normalise_code("7k3m-qx9d 2hpa").as_deref(),
Some("7K3MQX9D2HPA")
);
assert_eq!(
normalise_code("OOOO-IIII-LLLL").as_deref(),
Some("000011111111")
);
assert_eq!(normalise_code("7K3MQX9D2HP"), None);
assert_eq!(normalise_code("7K3MQX9D2HPU"), None);
assert_eq!(display_code("7K3MQX9D2HPA"), "7K3M-QX9D-2HPA");
}
#[test]
fn tokens_have_the_shape_of_section_8_2() {
let token = generate_token().unwrap();
assert!(crate::auth::is_token_shaped(&token), "{}", token.len());
assert_ne!(token, generate_token().unwrap());
}
}