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:
@@ -1,16 +1,212 @@
|
||||
//! Invites: E-3 accept (all three kinds), E-12 create, E-13 list, E-14 cancel (SPEC §8.3, FR-2).
|
||||
//! 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.
|
||||
//!
|
||||
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
|
||||
//! `not_implemented`).
|
||||
//! 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 crate::api::{IdPath, not_implemented};
|
||||
use crate::auth::AdminDevice;
|
||||
use crate::error::AppError;
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use base64::Engine as _;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use sqlx::SqliteConnection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::api::{ApiJson, IdPath, JsonBody, rows, validate};
|
||||
use crate::auth::{APP_VERSION_HEADER, AdminDevice, TOKEN_PREFIX, 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, CreateInviteRequest, Invite, InviteList, UuidSchema,
|
||||
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)
|
||||
))
|
||||
}
|
||||
|
||||
/// `<public_url>/i/<code>`.
|
||||
pub fn invite_link(public_url: &str, code: &str) -> String {
|
||||
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).
|
||||
pub fn wire(&self, public_url: &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)),
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
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,
|
||||
kind: String,
|
||||
target_user_id: Option<String>,
|
||||
expires_at: String,
|
||||
accepted_at: Option<String>,
|
||||
cancelled_at: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/invites/accept",
|
||||
@@ -25,8 +221,122 @@ use crate::wire::{
|
||||
(status = 422, response = Unprocessable),
|
||||
)
|
||||
)]
|
||||
pub async fn accept_invite() -> AppError {
|
||||
not_implemented()
|
||||
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 = 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 device_name = validate::text(
|
||||
"device_name",
|
||||
&body.device_name,
|
||||
1,
|
||||
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 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, 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(&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,
|
||||
user,
|
||||
device,
|
||||
household,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -44,8 +354,50 @@ pub async fn accept_invite() -> AppError {
|
||||
(status = 409, response = Conflict),
|
||||
)
|
||||
)]
|
||||
pub async fn create_invite(_admin: AdminDevice) -> AppError {
|
||||
not_implemented()
|
||||
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"));
|
||||
}
|
||||
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?
|
||||
}
|
||||
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?
|
||||
}
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
JsonBody(invite.wire(&state.config.public_url)),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -58,8 +410,33 @@ pub async fn create_invite(_admin: AdminDevice) -> AppError {
|
||||
(status = 403, response = Forbidden),
|
||||
)
|
||||
)]
|
||||
pub async fn list_invites(_admin: AdminDevice) -> AppError {
|
||||
not_implemented()
|
||||
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: serde_json::from_value(serde_json::Value::String(kind))
|
||||
.map_err(AppError::internal)?,
|
||||
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(
|
||||
@@ -77,6 +454,61 @@ pub async fn list_invites(_admin: AdminDevice) -> AppError {
|
||||
(status = 404, response = NotFound),
|
||||
)
|
||||
)]
|
||||
pub async fn cancel_invite(_admin: AdminDevice, IdPath(_invite_id): IdPath) -> AppError {
|
||||
not_implemented()
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user