From 4b30888dc256df56e993b7f64939eb0f3c4852ba Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 22:28:51 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- server/crates/kvitt-server/src/api/invites.rs | 145 +++++++++--------- .../crates/kvitt-server/src/cli/bootstrap.rs | 27 +--- .../kvitt-server/src/cli/invite_partner.rs | 47 ++---- server/crates/kvitt-server/src/cli/mod.rs | 33 +++- .../kvitt-server/src/cli/pair_device.rs | 18 +-- .../kvitt-server/src/cli/rotate_epoch.rs | 10 +- 6 files changed, 136 insertions(+), 144 deletions(-) diff --git a/server/crates/kvitt-server/src/api/invites.rs b/server/crates/kvitt-server/src/api/invites.rs index 6efc4e8..b83ebe3 100644 --- a/server/crates/kvitt-server/src/api/invites.rs +++ b/server/crates/kvitt-server/src/api/invites.rs @@ -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, +) -> Result { + 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::try_from(n).map_err(AppError::internal) } -fn app_version(headers: &HeaderMap) -> Option { - headers - .get(APP_VERSION_HEADER)? - .to_str() - .ok()? - .trim() - .parse::() - .ok() - .filter(|v| *v >= 0) -} - #[derive(sqlx::FromRow)] struct InviteRow { id: String, @@ -217,6 +221,57 @@ struct InviteRow { cancelled_at: Option, } +/// 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 { + 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, diff --git a/server/crates/kvitt-server/src/cli/bootstrap.rs b/server/crates/kvitt-server/src/cli/bootstrap.rs index 3d1c5f2..1083d44 100644 --- a/server/crates/kvitt-server/src/cli/bootstrap.rs +++ b/server/crates/kvitt-server/src/cli/bootstrap.rs @@ -6,12 +6,13 @@ use std::process::ExitCode; +use chrono::{DateTime, Utc}; + use crate::api::invites::{self, NewInvite}; use crate::config::Config; use crate::db::{self, Db}; use crate::error::AppError; use crate::wire::InviteKind; -use chrono::{DateTime, Utc}; /// Exit code when the household already exists (SPEC §10.4). pub const EXIT_ALREADY_INITIALIZED: u8 = 2; @@ -38,28 +39,14 @@ pub async fn bootstrap(db: &Db, now: DateTime) -> Result Ok(Outcome::Created(invite)) } -/// The text printed for a new invite (AC-1.1, AC-1.6): code in groups of four, landing link, expiry. -pub fn render(invite: &NewInvite, public_url: &str) -> String { - format!( - "Code: {}\nLink: {}\nExpires: {}\n", - invites::display_code(&invite.code), - invites::invite_link(public_url, &invite.code, None), - invite.expires_at - ) -} - /// Opens the database and runs [`bootstrap`] with the system clock. pub async fn run(config: &Config) -> ExitCode { - let result = async { - let pool = super::open_database(config).await?; - let outcome = bootstrap(&pool, Utc::now()).await; - pool.close().await; - outcome - } - .await; - match result { + match super::with_database(config, async |pool| bootstrap(pool, Utc::now()).await).await { Ok(Outcome::Created(invite)) => { - print!("{}", render(&invite, &config.public_url)); + print!( + "{}", + super::render_invite(&invite, &config.public_url, None) + ); ExitCode::SUCCESS } Ok(Outcome::AlreadyInitialized) => { diff --git a/server/crates/kvitt-server/src/cli/invite_partner.rs b/server/crates/kvitt-server/src/cli/invite_partner.rs index 82bf972..4b7a1e2 100644 --- a/server/crates/kvitt-server/src/cli/invite_partner.rs +++ b/server/crates/kvitt-server/src/cli/invite_partner.rs @@ -11,12 +11,12 @@ use std::process::ExitCode; use chrono::{DateTime, Utc}; -use crate::api::invites::{self, MAX_USERS, NewInvite}; +use crate::api::invites::{self, NewInvite}; use crate::api::rows; use crate::config::Config; use crate::db::{self, Db}; -use crate::error::AppError; -use crate::wire::{InviteKind, Role}; +use crate::error::{AppError, ErrorCode}; +use crate::wire::Role; /// Exit code when the household already has two users (D-113). pub const EXIT_HOUSEHOLD_FULL: u8 = 2; @@ -42,39 +42,26 @@ pub async fn invite_partner(db: &Db, now: DateTime) -> Result= MAX_USERS { - return Ok(Outcome::HouseholdFull); - } - let (admin_id, admin_name) = (admin.id, admin.display_name.clone()); - invites::cancel_pending(&mut tx, InviteKind::Partner, now).await?; - let invite = - invites::insert_invite(&mut tx, InviteKind::Partner, None, Some(admin_id), now).await?; + let invite = match invites::create_partner_invite(&mut tx, admin.id, now).await { + Ok(invite) => invite, + Err(e) if e.code == ErrorCode::HouseholdFull => return Ok(Outcome::HouseholdFull), + Err(e) => return Err(e), + }; tx.commit().await?; - Ok(Outcome::Created { invite, admin_name }) -} - -/// The printed text: as [`super::bootstrap::render`], with the admin named in the link (D-087). -pub fn render(invite: &NewInvite, public_url: &str, admin_name: &str) -> String { - format!( - "Code: {}\nLink: {}\nExpires: {}\n", - invites::display_code(&invite.code), - invites::invite_link(public_url, &invite.code, Some(admin_name)), - invite.expires_at - ) + Ok(Outcome::Created { + invite, + admin_name: admin.display_name.clone(), + }) } /// Opens the database and runs [`invite_partner`] with the system clock. pub async fn run(config: &Config) -> ExitCode { - let result = async { - let pool = super::open_database(config).await?; - let outcome = invite_partner(&pool, Utc::now()).await; - pool.close().await; - outcome - } - .await; - match result { + match super::with_database(config, async |pool| invite_partner(pool, Utc::now()).await).await { Ok(Outcome::Created { invite, admin_name }) => { - print!("{}", render(&invite, &config.public_url, &admin_name)); + print!( + "{}", + super::render_invite(&invite, &config.public_url, Some(&admin_name)) + ); ExitCode::SUCCESS } Ok(Outcome::HouseholdFull) => { diff --git a/server/crates/kvitt-server/src/cli/mod.rs b/server/crates/kvitt-server/src/cli/mod.rs index e3c2e00..734f40d 100644 --- a/server/crates/kvitt-server/src/cli/mod.rs +++ b/server/crates/kvitt-server/src/cli/mod.rs @@ -1,5 +1,4 @@ -//! `kvitt-server` subcommands (SPEC §10.4, exhaustive), all implemented: `serve`, `migrate` and `healthcheck` here, -//! `bootstrap` and `pair-device` (M3), `rotate-epoch` (M4) and `invite-partner` (D-113) in their own files. +//! `kvitt-server` subcommands (SPEC §10.4, exhaustive): `serve` and `migrate` here, the others in their own files. pub mod bootstrap; pub mod healthcheck; @@ -18,7 +17,9 @@ use clap::{Parser, Subcommand}; use tokio::net::TcpListener; use tokio::sync::Notify; +use crate::api::invites::{self, NewInvite}; use crate::config::Config; +use crate::db::Db; use crate::error::AppError; use crate::{AppState, db, router, tasks}; @@ -79,6 +80,29 @@ pub async fn run(command: Command, config: Config) -> ExitCode { } } +/// The text the invite subcommands print (AC-1.1, AC-1.6, D-113): the code in groups of four, the landing link +/// (naming the inviting admin with `from`, D-087) and the expiry. The code goes to stdout only, never to the log +/// (SPEC §8.7). +pub fn render_invite(invite: &NewInvite, public_url: &str, from: Option<&str>) -> String { + format!( + "Code: {}\nLink: {}\nExpires: {}\n", + invites::display_code(&invite.code), + invites::invite_link(public_url, &invite.code, from), + invite.expires_at + ) +} + +/// Opens the database (see [`open_database`]), runs `job` on it and closes the pool again. +pub async fn with_database( + config: &Config, + job: impl AsyncFnOnce(&Db) -> Result, +) -> Result { + let pool = open_database(config).await?; + let result = job(&pool).await; + pool.close().await; + result +} + /// Opens (creating if needed) the data directory and the database, and runs the migrations. pub async fn open_database(config: &Config) -> Result { std::fs::create_dir_all(&config.data_dir).map_err(|e| { @@ -90,8 +114,7 @@ pub async fn open_database(config: &Config) -> Result { } async fn migrate(config: Config) -> Result<(), AppError> { - let pool = open_database(&config).await?; - pool.close().await; + with_database(&config, async |_| Ok(())).await?; tracing::info!("migrations applied"); Ok(()) } @@ -100,7 +123,7 @@ async fn serve(config: Config) -> Result<(), AppError> { let pool = open_database(&config).await?; let bind = config.bind; // `tasks::start` installs the push queue (or logs once that push is disabled) and starts the scheduler. - let state = tasks::start(AppState::new(config, pool.clone())).await?; + let state = tasks::start(AppState::new(config, pool.clone())); let listener = TcpListener::bind(bind) .await diff --git a/server/crates/kvitt-server/src/cli/pair_device.rs b/server/crates/kvitt-server/src/cli/pair_device.rs index 23e1d0e..d4528cd 100644 --- a/server/crates/kvitt-server/src/cli/pair_device.rs +++ b/server/crates/kvitt-server/src/cli/pair_device.rs @@ -40,16 +40,16 @@ pub async fn pair_device( /// Opens the database and runs [`pair_device`] with the system clock. pub async fn run(config: &Config, user_id: &str) -> ExitCode { - let result = async { - let pool = super::open_database(config).await?; - let outcome = pair_device(&pool, user_id, Utc::now()).await; - pool.close().await; - outcome - } - .await; - match result { + match super::with_database(config, async |pool| { + pair_device(pool, user_id, Utc::now()).await + }) + .await + { Ok(Some(invite)) => { - print!("{}", super::bootstrap::render(&invite, &config.public_url)); + print!( + "{}", + super::render_invite(&invite, &config.public_url, None) + ); ExitCode::SUCCESS } Ok(None) => { diff --git a/server/crates/kvitt-server/src/cli/rotate_epoch.rs b/server/crates/kvitt-server/src/cli/rotate_epoch.rs index d53d158..a91cec5 100644 --- a/server/crates/kvitt-server/src/cli/rotate_epoch.rs +++ b/server/crates/kvitt-server/src/cli/rotate_epoch.rs @@ -21,15 +21,9 @@ pub async fn rotate(db: &Db) -> Result { Ok(epoch) } +/// Opens the database and runs [`rotate`]. pub async fn run(config: &Config) -> ExitCode { - let result = async { - let pool = super::open_database(config).await?; - let epoch = rotate(&pool).await; - pool.close().await; - epoch - } - .await; - match result { + match super::with_database(config, async |pool| rotate(pool).await).await { Ok(epoch) => { tracing::info!("sync_epoch rotated"); println!("sync_epoch rotated: {epoch}");