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
@@ -0,0 +1,202 @@
//! Input validation shared by the M3 handlers (SPEC §8.6, §9.6, §9.7).
//!
//! Strings are trimmed of leading and trailing whitespace and their length is counted in Unicode code points after
//! trimming; the trimmed value is what the server stores.
use chrono::{DateTime, Datelike, Utc};
use kvitt_domain::Period;
use crate::error::AppError;
use crate::time::STOCKHOLM;
/// Category icon tokens (SPEC §9.7, exhaustive for v1).
pub const ICONS: [&str; 30] = [
"shopping_cart",
"restaurant",
"local_cafe",
"home",
"bolt",
"water_drop",
"wifi",
"directions_car",
"train",
"local_gas_station",
"flight",
"hotel",
"luggage",
"movie",
"music_note",
"sports_esports",
"fitness_center",
"medical_services",
"medication",
"shopping_bag",
"checkroom",
"pets",
"child_care",
"school",
"card_giftcard",
"celebration",
"build",
"chair",
"subscriptions",
"category",
];
/// Colour tokens (SPEC §9.7, exhaustive for v1).
pub const COLORS: [&str; 12] = [
"green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey",
"indigo", "lime",
];
/// Account name: 1 to 60 code points (SPEC §9.6).
pub const ACCOUNT_NAME_MAX: usize = 60;
/// Category name: 1 to 40 code points.
pub const CATEGORY_NAME_MAX: usize = 40;
/// User display name: 1 to 40 code points.
pub const DISPLAY_NAME_MAX: usize = 40;
/// Device name: 1 to 60 code points.
pub const DEVICE_NAME_MAX: usize = 60;
/// Expense and template description: 1 to 100 code points.
pub const DESCRIPTION_MAX: usize = 100;
/// Expense and template notes: 0 to 1000 code points.
pub const NOTES_MAX: usize = 1000;
/// How far back a template's `start_period` may lie, in months (SPEC §9.6, AC-9.8).
pub const START_PERIOD_BACKFILL_MONTHS: u32 = 12;
/// Trims `value` and checks its length in code points is within `min..=max`. Returns the trimmed value.
pub fn text(field: &str, value: &str, min: usize, max: usize) -> Result<String, AppError> {
let trimmed = value.trim();
let len = trimmed.chars().count();
if len < min || len > max {
return Err(AppError::validation(
field,
format!("{field} must be {min} to {max} characters after trimming"),
));
}
Ok(trimmed.to_owned())
}
/// Notes: trimmed, 0 to `NOTES_MAX` code points, no control characters other than newline (SPEC §8.6).
pub fn notes(field: &str, value: &str) -> Result<String, AppError> {
let trimmed = text(field, value, 0, NOTES_MAX)?;
if trimmed.chars().any(|c| c.is_control() && c != '\n') {
return Err(AppError::validation(
field,
format!("{field} may not contain control characters other than newline"),
));
}
Ok(trimmed)
}
/// An icon token of SPEC §9.7.
pub fn icon(value: &str) -> Result<String, AppError> {
if ICONS.contains(&value) {
Ok(value.to_owned())
} else {
Err(AppError::validation(
"icon",
"icon is not a token of SPEC §9.7",
))
}
}
/// A colour token of SPEC §9.7.
pub fn color(value: &str) -> Result<String, AppError> {
if COLORS.contains(&value) {
Ok(value.to_owned())
} else {
Err(AppError::validation(
"color",
"color is not a token of SPEC §9.7",
))
}
}
/// `amount_ore` within 1 to 1 000 000 000 (SPEC §9.6).
pub fn amount(field: &str, amount_ore: i64) -> Result<i64, AppError> {
if kvitt_domain::amount_in_range(amount_ore) {
Ok(amount_ore)
} else {
Err(AppError::validation(field, "amount_out_of_range"))
}
}
/// A period `YYYY-MM`.
pub fn period(field: &str, value: &str) -> Result<Period, AppError> {
value
.parse::<Period>()
.map_err(|_| AppError::validation(field, format!("{field} must be YYYY-MM")))
}
/// The current month in Europe/Stockholm.
pub fn current_period(now: DateTime<Utc>) -> Result<Period, AppError> {
let local = now.with_timezone(&STOCKHOLM);
let year = u16::try_from(local.year()).map_err(AppError::internal)?;
let month = u8::try_from(local.month()).map_err(AppError::internal)?;
Period::new(year, month).ok_or_else(|| AppError::internal("current month out of range"))
}
/// `period` minus `months` months, or `None` before year 1.
pub fn months_before(period: Period, months: u32) -> Option<Period> {
let index = i64::from(period.year()) * 12 + i64::from(period.month()) - 1 - i64::from(months);
let year = u16::try_from(index.div_euclid(12)).ok()?;
let month = u8::try_from(index.rem_euclid(12) + 1).ok()?;
Period::new(year, month)
}
/// A template `start_period`: `YYYY-MM`, not earlier than 12 months before the current month (SPEC §9.6).
pub fn start_period(value: &str, now: DateTime<Utc>) -> Result<Period, AppError> {
let p = period("start_period", value)?;
let earliest = months_before(current_period(now)?, START_PERIOD_BACKFILL_MONTHS);
if earliest.is_some_and(|earliest| p < earliest) {
return Err(AppError::validation(
"start_period",
"start_period may be at most 12 months before the current month",
));
}
Ok(p)
}
/// `day_of_month` within 1 to 31.
pub fn day_of_month(value: i64) -> Result<u8, AppError> {
u8::try_from(value)
.ok()
.filter(|d| (1..=31).contains(d))
.ok_or_else(|| AppError::validation("day_of_month", "day_of_month must be 1 to 31"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_counts_code_points_after_trimming() {
assert_eq!(text("name", " Åsa ", 1, 3).unwrap(), "Åsa");
assert!(text("name", " ", 1, 3).is_err());
assert!(text("name", "Åsaa", 1, 3).is_err());
}
#[test]
fn notes_allow_newline_only() {
assert_eq!(notes("notes", "a\nb").unwrap(), "a\nb");
assert!(notes("notes", "a\tb").is_err());
assert!(notes("notes", "a\u{7}b").is_err());
}
#[test]
fn months_before_crosses_years() {
let p: Period = "2026-10".parse().unwrap();
assert_eq!(months_before(p, 12).unwrap().to_string(), "2025-10");
assert_eq!(months_before(p, 10).unwrap().to_string(), "2025-12");
assert_eq!(months_before(p, 9).unwrap().to_string(), "2026-01");
}
#[test]
fn tokens() {
assert!(icon("pets").is_ok());
assert!(icon("dog").is_err());
assert!(color("lime").is_ok());
assert!(color("black").is_err());
}
}