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:
@@ -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,
|
||||
|
||||
@@ -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<Utc>) -> Result<Outcome, AppError>
|
||||
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) => {
|
||||
|
||||
@@ -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<Utc>) -> Result<Outcome, AppE
|
||||
let Some(admin) = users.iter().find(|u| u.role == Role::Admin) else {
|
||||
return Ok(Outcome::NoAdmin);
|
||||
};
|
||||
if users.len() >= 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) => {
|
||||
|
||||
@@ -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<T>(
|
||||
config: &Config,
|
||||
job: impl AsyncFnOnce(&Db) -> Result<T, AppError>,
|
||||
) -> Result<T, AppError> {
|
||||
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<db::Db, AppError> {
|
||||
std::fs::create_dir_all(&config.data_dir).map_err(|e| {
|
||||
@@ -90,8 +114,7 @@ pub async fn open_database(config: &Config) -> Result<db::Db, AppError> {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -21,15 +21,9 @@ pub async fn rotate(db: &Db) -> Result<String, AppError> {
|
||||
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}");
|
||||
|
||||
Reference in New Issue
Block a user