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
This commit is contained in:
2026-10-18 22:28:51 +02:00
parent 8103dae2be
commit 4b30888dc2
6 changed files with 136 additions and 144 deletions
+73 -72
View File
@@ -1,5 +1,6 @@
//! 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 shared with the `bootstrap` and `pair-device` subcommands.
//! 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
@@ -15,7 +16,7 @@ use uuid::Uuid;
use crate::AppState;
use crate::api::{ApiJson, IdPath, JsonBody, rows, validate};
use crate::auth::{APP_VERSION_HEADER, AdminDevice, TOKEN_PREFIX, app_version_name, sha256_hex};
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};
@@ -142,6 +143,20 @@ pub async fn cancel_pending(
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,
@@ -196,17 +211,6 @@ async fn user_count(conn: &mut SqliteConnection) -> Result<usize, AppError> {
usize::try_from(n).map_err(AppError::internal)
}
fn app_version(headers: &HeaderMap) -> Option<i64> {
headers
.get(APP_VERSION_HEADER)?
.to_str()
.ok()?
.trim()
.parse::<i64>()
.ok()
.filter(|v| *v >= 0)
}
#[derive(sqlx::FromRow)]
struct InviteRow {
id: String,
@@ -217,6 +221,57 @@ struct InviteRow {
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",
@@ -250,12 +305,7 @@ pub async fn accept_invite(
if row.accepted_at.is_some() || row.cancelled_at.is_some() || expired {
return Err(invite_invalid());
}
let kind = match row.kind.as_str() {
"admin_setup" => InviteKind::AdminSetup,
"partner" => InviteKind::Partner,
"device" => InviteKind::Device,
other => return Err(AppError::internal(format!("unknown invite kind {other:?}"))),
};
let kind: InviteKind = rows::token(&row.kind)?;
let device_name = validate::text(
"device_name",
@@ -264,51 +314,7 @@ pub async fn accept_invite(
validate::DEVICE_NAME_MAX,
)?;
let now_ts = format_ts(now);
let user_id = match kind {
InviteKind::Device => {
let target = row
.target_user_id
.as_deref()
.ok_or_else(|| AppError::internal("device invite without target user"))?;
rows::uuid(target)?
}
InviteKind::AdminSetup | InviteKind::Partner => {
let display_name = body.display_name.as_deref().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(&mut tx).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(&mut tx).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 *tx)
.await?;
user_id
}
};
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();
@@ -320,7 +326,7 @@ pub async fn accept_invite(
.bind(user_id.to_string())
.bind(&device_name)
.bind(sha256_hex(&token))
.bind(app_version(&headers))
.bind(app_version_code(&headers))
.bind(app_version_name(&headers))
.bind(&now_ts)
.bind(&now_ts)
@@ -381,11 +387,7 @@ pub async fn create_invite(
)
.with_field("target_user_id"));
}
if user_count(&mut tx).await? >= MAX_USERS {
return Err(household_full());
}
cancel_pending(&mut tx, InviteKind::Partner, now).await?;
insert_invite(&mut tx, InviteKind::Partner, None, Some(admin.user_id), now).await?
create_partner_invite(&mut tx, admin.user_id, now).await?
}
CreateInviteKind::Device => {
let target = body.target_user_id.ok_or_else(|| {
@@ -442,8 +444,7 @@ pub async fn list_invites(
.map(|(id, kind, target, created_at, expires_at)| {
Ok(Invite {
id: rows::uuid(&id)?,
kind: serde_json::from_value(serde_json::Value::String(kind))
.map_err(AppError::internal)?,
kind: rows::token(&kind)?,
target_user_id: target.as_deref().map(rows::uuid).transpose()?,
code: None,
link: None,