M3: server core API (E-3 to E-30, E-33), landing page, pictures, rate limiting, recurring generation, bootstrap and pair-device

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 22:00:58 +02:00
parent 6dce4c73a9
commit 7869ec48d8
38 changed files with 7623 additions and 141 deletions
@@ -1,11 +1,74 @@
//! `kvitt-server bootstrap` (FR-1, AC-1.1 to AC-1.3). M3 owns this file; until then it prints
//! `not implemented in M2a` and exits 2.
//! `kvitt-server bootstrap` (FR-1, AC-1.1 to AC-1.3).
//!
//! No users: cancels any pending `admin_setup` invite, creates a new one and prints its code, landing link and
//! expiry (exit 0). Users exist: `already initialized` on stderr, exit 2, nothing created. The code is printed to
//! stdout only, never logged (SPEC §8.7).
use std::process::ExitCode;
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};
pub async fn run(config: &Config) -> ExitCode {
let _ = config;
super::not_implemented("bootstrap")
/// Exit code when the household already exists (SPEC §10.4).
pub const EXIT_ALREADY_INITIALIZED: u8 = 2;
/// What `bootstrap` did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Created(NewInvite),
AlreadyInitialized,
}
/// Creates the `admin_setup` invite unless users exist.
pub async fn bootstrap(db: &Db, now: DateTime<Utc>) -> Result<Outcome, AppError> {
let mut tx = db::begin_immediate(db).await?;
let users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
.fetch_one(&mut *tx)
.await?;
if users > 0 {
return Ok(Outcome::AlreadyInitialized);
}
invites::cancel_pending(&mut tx, InviteKind::AdminSetup, now).await?;
let invite = invites::insert_invite(&mut tx, InviteKind::AdminSetup, None, None, now).await?;
tx.commit().await?;
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),
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 {
Ok(Outcome::Created(invite)) => {
print!("{}", render(&invite, &config.public_url));
ExitCode::SUCCESS
}
Ok(Outcome::AlreadyInitialized) => {
eprintln!("already initialized");
ExitCode::from(EXIT_ALREADY_INITIALIZED)
}
Err(error) => {
eprintln!("bootstrap: {error}");
ExitCode::FAILURE
}
}
}
@@ -1,11 +1,64 @@
//! `kvitt-server pair-device --user <user_id>` (AC-1.6). M3 owns this file; until then it prints
//! `not implemented in M2a` and exits 2.
//! `kvitt-server pair-device --user <user_id>` (AC-1.6): a `device` invite for an existing user, printed like
//! `bootstrap`. The recovery path when every phone of a user is lost (SPEC §9.10). Exit 3 for an unknown user.
use std::process::ExitCode;
use crate::config::Config;
use chrono::{DateTime, Utc};
use uuid::Uuid;
pub async fn run(config: &Config, user_id: &str) -> ExitCode {
let _ = (config, user_id);
super::not_implemented("pair-device")
use crate::api::invites::{self, NewInvite};
use crate::config::Config;
use crate::db::{self, Db};
use crate::error::AppError;
use crate::wire::InviteKind;
/// Exit code for an unknown user (SPEC §10.4).
pub const EXIT_UNKNOWN_USER: u8 = 3;
/// Creates a device invite for `user_id`; `None` when no such user exists (or `user_id` is not a UUID).
pub async fn pair_device(
db: &Db,
user_id: &str,
now: DateTime<Utc>,
) -> Result<Option<NewInvite>, AppError> {
let Ok(user_id) = Uuid::parse_str(user_id.trim()) else {
return Ok(None);
};
let mut tx = db::begin_immediate(db).await?;
let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM users WHERE id = ?)")
.bind(user_id.to_string())
.fetch_one(&mut *tx)
.await?;
if !exists {
return Ok(None);
}
let invite =
invites::insert_invite(&mut tx, InviteKind::Device, Some(user_id), None, now).await?;
tx.commit().await?;
Ok(Some(invite))
}
/// 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 {
Ok(Some(invite)) => {
print!("{}", super::bootstrap::render(&invite, &config.public_url));
ExitCode::SUCCESS
}
Ok(None) => {
eprintln!("unknown user");
ExitCode::from(EXIT_UNKNOWN_USER)
}
Err(error) => {
eprintln!("pair-device: {error}");
ExitCode::FAILURE
}
}
}