From b6e5f2e9ca9fe837b00bfc42678a558a05b5ac88 Mon Sep 17 00:00:00 2001 From: Ivan Kinder Date: Wed, 13 May 2026 15:52:36 +0300 Subject: [PATCH 01/46] chore(config): add nextest profile to suppress passing test output --- .config/nextest.toml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .config/nextest.toml diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 00000000..4cb4afc6 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,7 @@ +# https://nexte.st/book/configuration + +[profile.default] +slow-timeout = { period = "30s", terminate-after = 2 } +test-threads = "num-cpus" +status-level = "slow" +final-status-level = "fail" From ff973661d7161920d0d8b10f72af141ee3a151d8 Mon Sep 17 00:00:00 2001 From: Ivan Kinder Date: Wed, 13 May 2026 15:52:47 +0300 Subject: [PATCH 02/46] feat(freeform): add deterministic parsers for providers/invites and usage policy --- module/iron_control_api/src/freeform/mod.rs | 4 + .../src/freeform/providers_and_invites.rs | 271 +++++++++++++++++ .../src/freeform/usage_policy.rs | 278 ++++++++++++++++++ module/iron_control_api/src/lib.rs | 4 + 4 files changed, 557 insertions(+) create mode 100644 module/iron_control_api/src/freeform/mod.rs create mode 100644 module/iron_control_api/src/freeform/providers_and_invites.rs create mode 100644 module/iron_control_api/src/freeform/usage_policy.rs diff --git a/module/iron_control_api/src/freeform/mod.rs b/module/iron_control_api/src/freeform/mod.rs new file mode 100644 index 00000000..b4a7862d --- /dev/null +++ b/module/iron_control_api/src/freeform/mod.rs @@ -0,0 +1,4 @@ +/// Parser for provider-key + invite-email paste blocks. +pub mod providers_and_invites; +/// Parser for usage-policy paste blocks. +pub mod usage_policy; diff --git a/module/iron_control_api/src/freeform/providers_and_invites.rs b/module/iron_control_api/src/freeform/providers_and_invites.rs new file mode 100644 index 00000000..f5cbbbbe --- /dev/null +++ b/module/iron_control_api/src/freeform/providers_and_invites.rs @@ -0,0 +1,271 @@ +use serde::{Deserialize, Serialize}; + +/// A supported inference provider. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum KnownProvider { + /// Google Gemini. + Gemini, + /// OpenAI. + OpenAi, + /// Anthropic. + Anthropic, + /// Mistral AI. + Mistral, + /// Cohere. + Cohere, +} + +impl KnownProvider { + fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "gemini" => Some(Self::Gemini), + "openai" => Some(Self::OpenAi), + "anthropic" => Some(Self::Anthropic), + "mistral" => Some(Self::Mistral), + "cohere" => Some(Self::Cohere), + _ => None, + } + } + + fn validate_key_prefix(&self, key: &str) -> bool { + match self { + Self::OpenAi => key.starts_with("sk-"), + Self::Anthropic => key.starts_with("sk-ant-"), + Self::Gemini => key.starts_with("AIzaSy"), + Self::Mistral => key.starts_with("sk-"), + Self::Cohere => !key.is_empty(), + } + } +} + +/// A single parsed provider key entry. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProviderEntry { + /// The resolved provider. + pub provider: KnownProvider, + /// The raw API key value. + pub key: String, +} + +/// Result of a successful parse of a providers-and-invites paste block. +#[derive(Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ParsedProvidersBlock { + /// Provider key entries extracted from `provider: key` lines. + pub providers: Vec, + /// Invite email addresses extracted from bare email lines (lowercased, deduplicated). + pub invites: Vec, +} + +/// A per-line parse error. +#[derive(Debug, PartialEq)] +pub struct LineError { + /// 1-indexed line number in the original input. + pub line: usize, + /// Trimmed content of the offending line. + pub content: String, + /// Reason the line was rejected. + pub kind: LineErrorKind, +} + +/// Reason a line was rejected during parsing. +#[derive(Debug, PartialEq)] +pub enum LineErrorKind { + /// The provider label is not in the supported set. + UnknownProvider(String), + /// The key does not start with the expected prefix for its provider. + InvalidKeyPrefix, + /// The value looks like it should be an email but is malformed. + InvalidEmail, + /// The line does not match `provider: key` or a bare email address. + MalformedLine, +} + +impl core::fmt::Display for LineError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let kind = match &self.kind { + LineErrorKind::UnknownProvider(p) => format!("unknown provider '{p}'"), + LineErrorKind::InvalidKeyPrefix => "invalid key prefix for provider".to_string(), + LineErrorKind::InvalidEmail => "invalid email address".to_string(), + LineErrorKind::MalformedLine => { + "cannot parse line — expected 'provider: key' or email".to_string() + } + }; + write!(f, "line {}: {} ({})", self.line, kind, self.content) + } +} + +fn is_valid_email(s: &str) -> bool { + let Some((local, domain)) = s.split_once('@') else { + return false; + }; + !local.is_empty() + && domain.contains('.') + && !domain.starts_with('.') + && !domain.ends_with('.') + && domain.len() > 2 +} + +/// Parses a paste block containing `provider: key` lines and bare email addresses. +/// +/// Returns the full parsed block or, on any error, a list of per-line diagnostics. +/// Apply is all-or-nothing: a single invalid line aborts the entire block. +/// Duplicate emails within the same paste are silently deduplicated. +pub fn parse(input: &str) -> Result> { + let mut block = ParsedProvidersBlock::default(); + let mut errors: Vec = Vec::new(); + let mut seen_emails: std::collections::HashSet = std::collections::HashSet::new(); + + for (idx, raw) in input.lines().enumerate() { + let line_no = idx + 1; + let trimmed = raw.trim(); + + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + if let Some((lhs, rhs)) = trimmed.split_once(':') { + let provider_str = lhs.trim(); + let key = rhs.trim().to_string(); + + if key.is_empty() { + errors.push(LineError { + line: line_no, + content: trimmed.to_string(), + kind: LineErrorKind::MalformedLine, + }); + continue; + } + + match KnownProvider::from_str(provider_str) { + None => errors.push(LineError { + line: line_no, + content: trimmed.to_string(), + kind: LineErrorKind::UnknownProvider(provider_str.to_string()), + }), + Some(provider) => { + if !provider.validate_key_prefix(&key) { + errors.push(LineError { + line: line_no, + content: trimmed.to_string(), + kind: LineErrorKind::InvalidKeyPrefix, + }); + } else { + block.providers.push(ProviderEntry { provider, key }); + } + } + } + } else if is_valid_email(trimmed) { + let lower = trimmed.to_lowercase(); + if seen_emails.insert(lower.clone()) { + block.invites.push(lower); + } + } else { + errors.push(LineError { + line: line_no, + content: trimmed.to_string(), + kind: LineErrorKind::MalformedLine, + }); + } + } + + if errors.is_empty() { + Ok(block) + } else { + Err(errors) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_canonical_deck_example() { + let input = "\ +gemini: AIzaSy...xxxx +openai: sk-proj-...xxxx +anthropic: sk-ant-...xxxx + +alice@acmecorp.com +bob@acmecorp.com +carol@acmecorp.com"; + + let block = parse(input).unwrap(); + assert_eq!(block.providers.len(), 3); + assert_eq!(block.providers[0].provider, KnownProvider::Gemini); + assert_eq!(block.providers[1].provider, KnownProvider::OpenAi); + assert_eq!(block.providers[2].provider, KnownProvider::Anthropic); + assert_eq!( + block.invites, + vec!["alice@acmecorp.com", "bob@acmecorp.com", "carol@acmecorp.com"] + ); + } + + #[test] + fn ignores_blank_lines_and_comments() { + let input = "\ +# workspace bootstrap +openai: sk-proj-key + +# team +alice@example.com"; + + let block = parse(input).unwrap(); + assert_eq!(block.providers.len(), 1); + assert_eq!(block.invites.len(), 1); + } + + #[test] + fn rejects_unknown_provider() { + let input = "groq: sk-groq-xxxx"; + let errs = parse(input).unwrap_err(); + assert_eq!(errs.len(), 1); + assert!(matches!(&errs[0].kind, LineErrorKind::UnknownProvider(p) if p == "groq")); + } + + #[test] + fn rejects_wrong_key_prefix_for_provider() { + let input = "anthropic: sk-proj-wrong"; + let errs = parse(input).unwrap_err(); + assert_eq!(errs.len(), 1); + assert_eq!(errs[0].kind, LineErrorKind::InvalidKeyPrefix); + } + + #[test] + fn rejects_invalid_email() { + let input = "notanemail"; + let errs = parse(input).unwrap_err(); + assert_eq!(errs.len(), 1); + assert_eq!(errs[0].kind, LineErrorKind::MalformedLine); + } + + #[test] + fn deduplicates_emails_silently() { + let input = "alice@example.com\nAlice@example.com"; + let block = parse(input).unwrap(); + assert_eq!(block.invites, vec!["alice@example.com"]); + } + + #[test] + fn all_or_nothing_on_mixed_valid_invalid() { + let input = "openai: sk-proj-good\nbadprovider: key"; + let errs = parse(input).unwrap_err(); + assert_eq!(errs.len(), 1); + } + + #[test] + fn empty_input_returns_empty_block() { + let block = parse("").unwrap(); + assert!(block.providers.is_empty()); + assert!(block.invites.is_empty()); + } + + #[test] + fn idempotent_reparse_produces_same_result() { + let input = "openai: sk-proj-abc\nalice@example.com"; + let first = parse(input).unwrap(); + let second = parse(input).unwrap(); + assert_eq!(first, second); + } +} diff --git a/module/iron_control_api/src/freeform/usage_policy.rs b/module/iron_control_api/src/freeform/usage_policy.rs new file mode 100644 index 00000000..179c1d87 --- /dev/null +++ b/module/iron_control_api/src/freeform/usage_policy.rs @@ -0,0 +1,278 @@ +use serde::{Deserialize, Serialize}; + +/// Billing period for a workspace spending cap. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CapPeriod { + /// Rolling 24-hour window. + Day, + /// Rolling 7-day window. + Week, + /// Rolling 30-day window. + Month, +} + +impl CapPeriod { + fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "day" => Some(Self::Day), + "week" => Some(Self::Week), + "month" => Some(Self::Month), + _ => None, + } + } +} + +/// A workspace-wide spending cap. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpendingCap { + /// Cap amount in US cents (e.g. `$100` -> `10000`). + pub amount_cents: u64, + /// Billing period over which the cap resets. + pub period: CapPeriod, +} + +/// Result of a successful parse of a usage-policy paste block. +#[derive(Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ParsedPolicy { + /// Optional workspace-wide spending cap from `limit all users $N/`. + pub spending_cap: Option, + /// Default model all members receive from `default: `. + pub default_model: Option, + /// Models gated behind an admin-approval request from `requestable: model[, ...]`. + pub requestable_models: Vec, +} + +/// A per-line parse error. +#[derive(Debug, PartialEq)] +pub struct ParseError { + /// 1-indexed line number in the original input. + pub line: usize, + /// Trimmed content of the offending line. + pub content: String, + /// Reason the line was rejected. + pub kind: ParseErrorKind, +} + +/// Reason a line was rejected during policy parsing. +#[derive(Debug, PartialEq)] +pub enum ParseErrorKind { + /// The `$N` amount is missing or not a valid number. + InvalidSpendAmount, + /// The period token is not `day`, `week`, or `month`. + InvalidPeriod(String), + /// A model identifier is required but was empty. + EmptyModelId, + /// The line does not match any supported policy directive. + MalformedLine, +} + +impl core::fmt::Display for ParseError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let kind = match &self.kind { + ParseErrorKind::InvalidSpendAmount => "invalid spend amount — expected '$N'".to_string(), + ParseErrorKind::InvalidPeriod(p) => { + format!("invalid period '{p}' — supported: day, week, month") + } + ParseErrorKind::EmptyModelId => "model id must not be empty".to_string(), + ParseErrorKind::MalformedLine => "cannot parse line".to_string(), + }; + write!(f, "line {}: {} ({})", self.line, kind, self.content) + } +} + +/// Parses a usage-policy paste block. +/// +/// Supported directives (keywords are case-insensitive): +/// - `limit all users $N/` — workspace spending cap +/// - `default: ` or `default model: ` +/// - `requestable: model[, model, ...]` +/// +/// Returns the parsed policy or per-line errors (all-or-nothing). +pub fn parse(input: &str) -> Result> { + let mut policy = ParsedPolicy::default(); + let mut errors: Vec = Vec::new(); + + for (idx, raw) in input.lines().enumerate() { + let line_no = idx + 1; + let trimmed = raw.trim(); + + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + let lower = trimmed.to_lowercase(); + + if lower.starts_with("limit all users") { + let rest = trimmed["limit all users".len()..].trim(); + match parse_spend_cap(rest) { + Ok(cap) => policy.spending_cap = Some(cap), + Err(kind) => errors.push(ParseError { + line: line_no, + content: trimmed.to_string(), + kind, + }), + } + } else if lower.starts_with("default model:") { + let model = trimmed["default model:".len()..].trim().to_string(); + if model.is_empty() { + errors.push(ParseError { + line: line_no, + content: trimmed.to_string(), + kind: ParseErrorKind::EmptyModelId, + }); + } else { + policy.default_model = Some(model); + } + } else if lower.starts_with("default:") { + let model = trimmed["default:".len()..].trim().to_string(); + if model.is_empty() { + errors.push(ParseError { + line: line_no, + content: trimmed.to_string(), + kind: ParseErrorKind::EmptyModelId, + }); + } else { + policy.default_model = Some(model); + } + } else if lower.starts_with("requestable:") { + let rest = trimmed["requestable:".len()..].trim(); + let models: Vec = rest + .split(',') + .map(|m| m.trim().to_string()) + .filter(|m| !m.is_empty()) + .collect(); + + if models.is_empty() { + errors.push(ParseError { + line: line_no, + content: trimmed.to_string(), + kind: ParseErrorKind::EmptyModelId, + }); + } else { + policy.requestable_models.extend(models); + } + } else { + errors.push(ParseError { + line: line_no, + content: trimmed.to_string(), + kind: ParseErrorKind::MalformedLine, + }); + } + } + + if errors.is_empty() { + Ok(policy) + } else { + Err(errors) + } +} + +fn parse_spend_cap(s: &str) -> Result { + let s = s.strip_prefix('$').ok_or(ParseErrorKind::InvalidSpendAmount)?; + let (amount_str, period_str) = s + .split_once('/') + .ok_or(ParseErrorKind::InvalidSpendAmount)?; + + let amount_dollars: f64 = amount_str + .trim() + .parse() + .map_err(|_| ParseErrorKind::InvalidSpendAmount)?; + let amount_cents = (amount_dollars * 100.0).round() as u64; + + let period = CapPeriod::from_str(period_str.trim()) + .ok_or_else(|| ParseErrorKind::InvalidPeriod(period_str.trim().to_string()))?; + + Ok(SpendingCap { amount_cents, period }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_multiline_policy() { + let input = "\ +limit all users $100/week +default: gpt-5.4-mini +requestable: claude-4-6-sonnet, gemini-3.1-pro-preview"; + + let policy = parse(input).unwrap(); + assert_eq!( + policy.spending_cap, + Some(SpendingCap { amount_cents: 10000, period: CapPeriod::Week }) + ); + assert_eq!(policy.default_model.as_deref(), Some("gpt-5.4-mini")); + assert_eq!( + policy.requestable_models, + vec!["claude-4-6-sonnet", "gemini-3.1-pro-preview"] + ); + } + + #[test] + fn single_line_deck_format_is_rejected() { + // The deck shows directives on one line for display; the grammar expects one directive per line. + let input = "limit all users $100/week - default: gpt-5.4-mini - requestable: claude-4-6-sonnet"; + assert!(parse(input).is_err()); + } + + #[test] + fn parses_default_model_variant() { + let input = "default model: gpt-4o"; + let policy = parse(input).unwrap(); + assert_eq!(policy.default_model.as_deref(), Some("gpt-4o")); + } + + #[test] + fn rejects_unknown_period() { + let input = "limit all users $50/fortnight"; + let errs = parse(input).unwrap_err(); + assert!(matches!(&errs[0].kind, ParseErrorKind::InvalidPeriod(p) if p == "fortnight")); + } + + #[test] + fn rejects_invalid_amount() { + let input = "limit all users 50/week"; + let errs = parse(input).unwrap_err(); + assert_eq!(errs[0].kind, ParseErrorKind::InvalidSpendAmount); + } + + #[test] + fn rejects_empty_model() { + let input = "default:"; + let errs = parse(input).unwrap_err(); + assert_eq!(errs[0].kind, ParseErrorKind::EmptyModelId); + } + + #[test] + fn ignores_blank_lines_and_comments() { + let input = "\ +# workspace policy +limit all users $200/month + +default: gpt-4o"; + + let policy = parse(input).unwrap(); + assert_eq!(policy.spending_cap.unwrap().period, CapPeriod::Month); + assert_eq!(policy.default_model.as_deref(), Some("gpt-4o")); + } + + #[test] + fn rejects_per_user_limit_as_malformed() { + let input = "limit user@example.com $50/week"; + let errs = parse(input).unwrap_err(); + assert_eq!(errs[0].kind, ParseErrorKind::MalformedLine); + } + + #[test] + fn idempotent_reparse() { + let input = "limit all users $100/week\ndefault: gpt-4o\nrequestable: claude-4-6-sonnet"; + assert_eq!(parse(input), parse(input)); + } + + #[test] + fn all_or_nothing_on_mixed_error() { + let input = "limit all users $100/week\nbadline"; + assert!(parse(input).is_err()); + } +} diff --git a/module/iron_control_api/src/lib.rs b/module/iron_control_api/src/lib.rs index b39615e9..e84776f2 100644 --- a/module/iron_control_api/src/lib.rs +++ b/module/iron_control_api/src/lib.rs @@ -207,6 +207,10 @@ pub mod ic_token; #[cfg(feature = "enabled")] pub mod rate_limiter; +#[cfg(feature = "enabled")] +/// Deterministic paste parsers for FreeForm onboarding surfaces. +pub mod freeform; + #[cfg(feature = "enabled")] pub use iron_secrets::ip_token; From 953dc028fb14f2f7eeba503449ee416af46f9e4f Mon Sep 17 00:00:00 2001 From: Ivan Kinder Date: Thu, 14 May 2026 15:47:46 +0300 Subject: [PATCH 03/46] refactor(freeform): split providers-and-invites into separate parsers; drop requestable from usage policy --- .../iron_control_api/src/freeform/invites.rs | 72 +++++ module/iron_control_api/src/freeform/mod.rs | 6 +- .../src/freeform/providers.rs | 150 ++++++++++ .../src/freeform/providers_and_invites.rs | 271 ------------------ .../src/freeform/usage_policy.rs | 131 ++------- module/iron_control_api/src/lib.rs | 2 +- .../tests/freeform_invites_test.rs | 72 +++++ .../tests/freeform_providers_test.rs | 74 +++++ .../tests/freeform_usage_policy_test.rs | 85 ++++++ 9 files changed, 474 insertions(+), 389 deletions(-) create mode 100644 module/iron_control_api/src/freeform/invites.rs create mode 100644 module/iron_control_api/src/freeform/providers.rs delete mode 100644 module/iron_control_api/src/freeform/providers_and_invites.rs create mode 100644 module/iron_control_api/tests/freeform_invites_test.rs create mode 100644 module/iron_control_api/tests/freeform_providers_test.rs create mode 100644 module/iron_control_api/tests/freeform_usage_policy_test.rs diff --git a/module/iron_control_api/src/freeform/invites.rs b/module/iron_control_api/src/freeform/invites.rs new file mode 100644 index 00000000..fa4f9475 --- /dev/null +++ b/module/iron_control_api/src/freeform/invites.rs @@ -0,0 +1,72 @@ +/// A per-line parse error for an invite list. +#[derive(Debug, PartialEq)] +pub struct ParseError { + /// 1-indexed line number in the original input. + pub line: usize, + /// Trimmed content of the offending line. + pub content: String, +} + +impl core::fmt::Display for ParseError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + f, + "line {}: invalid email address ({})", + self.line, self.content + ) + } +} + +fn is_valid_email(s: &str) -> bool { + let Some((local, domain)) = s.split_once('@') else { + return false; + }; + !local.is_empty() + && domain.contains('.') + && !domain.starts_with('.') + && !domain.ends_with('.') + && domain.len() > 2 +} + +/// Parses a paste block of email addresses (one per line). +/// +/// Each non-blank, non-comment line must be a valid email address. +/// Duplicate addresses within the same paste are silently deduplicated. +/// Apply is all-or-nothing: a single invalid line aborts the entire block. +/// All addresses are lowercased in the output. +/// +/// # Errors +/// +/// Returns a list of [`ParseError`]s if any line is not a valid email address. +pub fn parse(input: &str) -> Result, Vec> { + let mut emails: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + for (idx, raw) in input.lines().enumerate() { + let line_no = idx + 1; + let trimmed = raw.trim(); + + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + if is_valid_email(trimmed) { + let lower = trimmed.to_lowercase(); + if seen.insert(lower.clone()) { + emails.push(lower); + } + } else { + errors.push(ParseError { + line: line_no, + content: trimmed.to_string(), + }); + } + } + + if errors.is_empty() { + Ok(emails) + } else { + Err(errors) + } +} diff --git a/module/iron_control_api/src/freeform/mod.rs b/module/iron_control_api/src/freeform/mod.rs index b4a7862d..036c46ff 100644 --- a/module/iron_control_api/src/freeform/mod.rs +++ b/module/iron_control_api/src/freeform/mod.rs @@ -1,4 +1,6 @@ -/// Parser for provider-key + invite-email paste blocks. -pub mod providers_and_invites; +/// Parser for invite email-address paste blocks. +pub mod invites; +/// Parser for `provider: key` paste blocks. +pub mod providers; /// Parser for usage-policy paste blocks. pub mod usage_policy; diff --git a/module/iron_control_api/src/freeform/providers.rs b/module/iron_control_api/src/freeform/providers.rs new file mode 100644 index 00000000..82a99769 --- /dev/null +++ b/module/iron_control_api/src/freeform/providers.rs @@ -0,0 +1,150 @@ +use serde::{Deserialize, Serialize}; + +/// A supported inference provider. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum KnownProvider { + /// Google Gemini. + Gemini, + /// `OpenAI`. + OpenAi, + /// Anthropic. + Anthropic, + /// Mistral AI. + Mistral, + /// Cohere. + Cohere, +} + +impl KnownProvider { + fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "gemini" => Some(Self::Gemini), + "openai" => Some(Self::OpenAi), + "anthropic" => Some(Self::Anthropic), + "mistral" => Some(Self::Mistral), + "cohere" => Some(Self::Cohere), + _ => None, + } + } + + fn validate_key_prefix(&self, key: &str) -> bool { + match self { + Self::OpenAi | Self::Mistral => key.starts_with("sk-"), + Self::Anthropic => key.starts_with("sk-ant-"), + Self::Gemini => key.starts_with("AIzaSy"), + Self::Cohere => !key.is_empty(), + } + } +} + +/// A single parsed provider key entry. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProviderEntry { + /// The resolved provider. + pub provider: KnownProvider, + /// The raw API key value. + pub key: String, +} + +/// A per-line parse error. +#[derive(Debug, PartialEq)] +pub struct LineError { + /// 1-indexed line number in the original input. + pub line: usize, + /// Trimmed content of the offending line. + pub content: String, + /// Reason the line was rejected. + pub kind: LineErrorKind, +} + +/// Reason a line was rejected during provider-key parsing. +#[derive(Debug, PartialEq)] +pub enum LineErrorKind { + /// The provider label is not in the supported set. + UnknownProvider(String), + /// The key does not start with the expected prefix for its provider. + InvalidKeyPrefix, + /// The line does not match the expected `provider: key` format. + MalformedLine, +} + +impl core::fmt::Display for LineError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let kind = match &self.kind { + LineErrorKind::UnknownProvider(p) => format!("unknown provider '{p}'"), + LineErrorKind::InvalidKeyPrefix => "invalid key prefix for provider".to_string(), + LineErrorKind::MalformedLine => "expected 'provider: key' format".to_string(), + }; + write!(f, "line {}: {} ({})", self.line, kind, self.content) + } +} + +/// Parses a paste block of `provider: key` lines. +/// +/// Each non-blank, non-comment line must be in the form `provider: key` where +/// `provider` is one of `gemini`, `openai`, `anthropic`, `mistral`, `cohere`. +/// Apply is all-or-nothing: a single invalid line aborts the entire block. +/// +/// # Errors +/// +/// Returns a list of [`LineError`]s if any line fails validation. +pub fn parse(input: &str) -> Result, Vec> { + let mut entries: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + + for (idx, raw) in input.lines().enumerate() { + let line_no = idx + 1; + let trimmed = raw.trim(); + + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + let Some((lhs, rhs)) = trimmed.split_once(':') else { + errors.push(LineError { + line: line_no, + content: trimmed.to_string(), + kind: LineErrorKind::MalformedLine, + }); + continue; + }; + + let provider_str = lhs.trim(); + let key = rhs.trim().to_string(); + + if key.is_empty() { + errors.push(LineError { + line: line_no, + content: trimmed.to_string(), + kind: LineErrorKind::MalformedLine, + }); + continue; + } + + match KnownProvider::from_str(provider_str) { + None => errors.push(LineError { + line: line_no, + content: trimmed.to_string(), + kind: LineErrorKind::UnknownProvider(provider_str.to_string()), + }), + Some(provider) => { + if provider.validate_key_prefix(&key) { + entries.push(ProviderEntry { provider, key }); + } else { + errors.push(LineError { + line: line_no, + content: trimmed.to_string(), + kind: LineErrorKind::InvalidKeyPrefix, + }); + } + } + } + } + + if errors.is_empty() { + Ok(entries) + } else { + Err(errors) + } +} diff --git a/module/iron_control_api/src/freeform/providers_and_invites.rs b/module/iron_control_api/src/freeform/providers_and_invites.rs deleted file mode 100644 index f5cbbbbe..00000000 --- a/module/iron_control_api/src/freeform/providers_and_invites.rs +++ /dev/null @@ -1,271 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// A supported inference provider. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum KnownProvider { - /// Google Gemini. - Gemini, - /// OpenAI. - OpenAi, - /// Anthropic. - Anthropic, - /// Mistral AI. - Mistral, - /// Cohere. - Cohere, -} - -impl KnownProvider { - fn from_str(s: &str) -> Option { - match s.to_lowercase().as_str() { - "gemini" => Some(Self::Gemini), - "openai" => Some(Self::OpenAi), - "anthropic" => Some(Self::Anthropic), - "mistral" => Some(Self::Mistral), - "cohere" => Some(Self::Cohere), - _ => None, - } - } - - fn validate_key_prefix(&self, key: &str) -> bool { - match self { - Self::OpenAi => key.starts_with("sk-"), - Self::Anthropic => key.starts_with("sk-ant-"), - Self::Gemini => key.starts_with("AIzaSy"), - Self::Mistral => key.starts_with("sk-"), - Self::Cohere => !key.is_empty(), - } - } -} - -/// A single parsed provider key entry. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ProviderEntry { - /// The resolved provider. - pub provider: KnownProvider, - /// The raw API key value. - pub key: String, -} - -/// Result of a successful parse of a providers-and-invites paste block. -#[derive(Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct ParsedProvidersBlock { - /// Provider key entries extracted from `provider: key` lines. - pub providers: Vec, - /// Invite email addresses extracted from bare email lines (lowercased, deduplicated). - pub invites: Vec, -} - -/// A per-line parse error. -#[derive(Debug, PartialEq)] -pub struct LineError { - /// 1-indexed line number in the original input. - pub line: usize, - /// Trimmed content of the offending line. - pub content: String, - /// Reason the line was rejected. - pub kind: LineErrorKind, -} - -/// Reason a line was rejected during parsing. -#[derive(Debug, PartialEq)] -pub enum LineErrorKind { - /// The provider label is not in the supported set. - UnknownProvider(String), - /// The key does not start with the expected prefix for its provider. - InvalidKeyPrefix, - /// The value looks like it should be an email but is malformed. - InvalidEmail, - /// The line does not match `provider: key` or a bare email address. - MalformedLine, -} - -impl core::fmt::Display for LineError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - let kind = match &self.kind { - LineErrorKind::UnknownProvider(p) => format!("unknown provider '{p}'"), - LineErrorKind::InvalidKeyPrefix => "invalid key prefix for provider".to_string(), - LineErrorKind::InvalidEmail => "invalid email address".to_string(), - LineErrorKind::MalformedLine => { - "cannot parse line — expected 'provider: key' or email".to_string() - } - }; - write!(f, "line {}: {} ({})", self.line, kind, self.content) - } -} - -fn is_valid_email(s: &str) -> bool { - let Some((local, domain)) = s.split_once('@') else { - return false; - }; - !local.is_empty() - && domain.contains('.') - && !domain.starts_with('.') - && !domain.ends_with('.') - && domain.len() > 2 -} - -/// Parses a paste block containing `provider: key` lines and bare email addresses. -/// -/// Returns the full parsed block or, on any error, a list of per-line diagnostics. -/// Apply is all-or-nothing: a single invalid line aborts the entire block. -/// Duplicate emails within the same paste are silently deduplicated. -pub fn parse(input: &str) -> Result> { - let mut block = ParsedProvidersBlock::default(); - let mut errors: Vec = Vec::new(); - let mut seen_emails: std::collections::HashSet = std::collections::HashSet::new(); - - for (idx, raw) in input.lines().enumerate() { - let line_no = idx + 1; - let trimmed = raw.trim(); - - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - - if let Some((lhs, rhs)) = trimmed.split_once(':') { - let provider_str = lhs.trim(); - let key = rhs.trim().to_string(); - - if key.is_empty() { - errors.push(LineError { - line: line_no, - content: trimmed.to_string(), - kind: LineErrorKind::MalformedLine, - }); - continue; - } - - match KnownProvider::from_str(provider_str) { - None => errors.push(LineError { - line: line_no, - content: trimmed.to_string(), - kind: LineErrorKind::UnknownProvider(provider_str.to_string()), - }), - Some(provider) => { - if !provider.validate_key_prefix(&key) { - errors.push(LineError { - line: line_no, - content: trimmed.to_string(), - kind: LineErrorKind::InvalidKeyPrefix, - }); - } else { - block.providers.push(ProviderEntry { provider, key }); - } - } - } - } else if is_valid_email(trimmed) { - let lower = trimmed.to_lowercase(); - if seen_emails.insert(lower.clone()) { - block.invites.push(lower); - } - } else { - errors.push(LineError { - line: line_no, - content: trimmed.to_string(), - kind: LineErrorKind::MalformedLine, - }); - } - } - - if errors.is_empty() { - Ok(block) - } else { - Err(errors) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_canonical_deck_example() { - let input = "\ -gemini: AIzaSy...xxxx -openai: sk-proj-...xxxx -anthropic: sk-ant-...xxxx - -alice@acmecorp.com -bob@acmecorp.com -carol@acmecorp.com"; - - let block = parse(input).unwrap(); - assert_eq!(block.providers.len(), 3); - assert_eq!(block.providers[0].provider, KnownProvider::Gemini); - assert_eq!(block.providers[1].provider, KnownProvider::OpenAi); - assert_eq!(block.providers[2].provider, KnownProvider::Anthropic); - assert_eq!( - block.invites, - vec!["alice@acmecorp.com", "bob@acmecorp.com", "carol@acmecorp.com"] - ); - } - - #[test] - fn ignores_blank_lines_and_comments() { - let input = "\ -# workspace bootstrap -openai: sk-proj-key - -# team -alice@example.com"; - - let block = parse(input).unwrap(); - assert_eq!(block.providers.len(), 1); - assert_eq!(block.invites.len(), 1); - } - - #[test] - fn rejects_unknown_provider() { - let input = "groq: sk-groq-xxxx"; - let errs = parse(input).unwrap_err(); - assert_eq!(errs.len(), 1); - assert!(matches!(&errs[0].kind, LineErrorKind::UnknownProvider(p) if p == "groq")); - } - - #[test] - fn rejects_wrong_key_prefix_for_provider() { - let input = "anthropic: sk-proj-wrong"; - let errs = parse(input).unwrap_err(); - assert_eq!(errs.len(), 1); - assert_eq!(errs[0].kind, LineErrorKind::InvalidKeyPrefix); - } - - #[test] - fn rejects_invalid_email() { - let input = "notanemail"; - let errs = parse(input).unwrap_err(); - assert_eq!(errs.len(), 1); - assert_eq!(errs[0].kind, LineErrorKind::MalformedLine); - } - - #[test] - fn deduplicates_emails_silently() { - let input = "alice@example.com\nAlice@example.com"; - let block = parse(input).unwrap(); - assert_eq!(block.invites, vec!["alice@example.com"]); - } - - #[test] - fn all_or_nothing_on_mixed_valid_invalid() { - let input = "openai: sk-proj-good\nbadprovider: key"; - let errs = parse(input).unwrap_err(); - assert_eq!(errs.len(), 1); - } - - #[test] - fn empty_input_returns_empty_block() { - let block = parse("").unwrap(); - assert!(block.providers.is_empty()); - assert!(block.invites.is_empty()); - } - - #[test] - fn idempotent_reparse_produces_same_result() { - let input = "openai: sk-proj-abc\nalice@example.com"; - let first = parse(input).unwrap(); - let second = parse(input).unwrap(); - assert_eq!(first, second); - } -} diff --git a/module/iron_control_api/src/freeform/usage_policy.rs b/module/iron_control_api/src/freeform/usage_policy.rs index 179c1d87..68a36510 100644 --- a/module/iron_control_api/src/freeform/usage_policy.rs +++ b/module/iron_control_api/src/freeform/usage_policy.rs @@ -39,8 +39,6 @@ pub struct ParsedPolicy { pub spending_cap: Option, /// Default model all members receive from `default: `. pub default_model: Option, - /// Models gated behind an admin-approval request from `requestable: model[, ...]`. - pub requestable_models: Vec, } /// A per-line parse error. @@ -86,9 +84,10 @@ impl core::fmt::Display for ParseError { /// Supported directives (keywords are case-insensitive): /// - `limit all users $N/` — workspace spending cap /// - `default: ` or `default model: ` -/// - `requestable: model[, model, ...]` /// -/// Returns the parsed policy or per-line errors (all-or-nothing). +/// # Errors +/// +/// Returns a list of [`ParseError`]s if any directive is malformed (all-or-nothing). pub fn parse(input: &str) -> Result> { let mut policy = ParsedPolicy::default(); let mut errors: Vec = Vec::new(); @@ -135,23 +134,6 @@ pub fn parse(input: &str) -> Result> { } else { policy.default_model = Some(model); } - } else if lower.starts_with("requestable:") { - let rest = trimmed["requestable:".len()..].trim(); - let models: Vec = rest - .split(',') - .map(|m| m.trim().to_string()) - .filter(|m| !m.is_empty()) - .collect(); - - if models.is_empty() { - errors.push(ParseError { - line: line_no, - content: trimmed.to_string(), - kind: ParseErrorKind::EmptyModelId, - }); - } else { - policy.requestable_models.extend(models); - } } else { errors.push(ParseError { line: line_no, @@ -169,7 +151,9 @@ pub fn parse(input: &str) -> Result> { } fn parse_spend_cap(s: &str) -> Result { - let s = s.strip_prefix('$').ok_or(ParseErrorKind::InvalidSpendAmount)?; + let s = s + .strip_prefix('$') + .ok_or(ParseErrorKind::InvalidSpendAmount)?; let (amount_str, period_str) = s .split_once('/') .ok_or(ParseErrorKind::InvalidSpendAmount)?; @@ -178,101 +162,18 @@ fn parse_spend_cap(s: &str) -> Result { .trim() .parse() .map_err(|_| ParseErrorKind::InvalidSpendAmount)?; - let amount_cents = (amount_dollars * 100.0).round() as u64; + let cents = (amount_dollars * 100.0).round(); + if !cents.is_finite() || cents < 0.0 || cents > u64::MAX as f64 { + return Err(ParseErrorKind::InvalidSpendAmount); + } + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let amount_cents = cents as u64; let period = CapPeriod::from_str(period_str.trim()) .ok_or_else(|| ParseErrorKind::InvalidPeriod(period_str.trim().to_string()))?; - Ok(SpendingCap { amount_cents, period }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_multiline_policy() { - let input = "\ -limit all users $100/week -default: gpt-5.4-mini -requestable: claude-4-6-sonnet, gemini-3.1-pro-preview"; - - let policy = parse(input).unwrap(); - assert_eq!( - policy.spending_cap, - Some(SpendingCap { amount_cents: 10000, period: CapPeriod::Week }) - ); - assert_eq!(policy.default_model.as_deref(), Some("gpt-5.4-mini")); - assert_eq!( - policy.requestable_models, - vec!["claude-4-6-sonnet", "gemini-3.1-pro-preview"] - ); - } - - #[test] - fn single_line_deck_format_is_rejected() { - // The deck shows directives on one line for display; the grammar expects one directive per line. - let input = "limit all users $100/week - default: gpt-5.4-mini - requestable: claude-4-6-sonnet"; - assert!(parse(input).is_err()); - } - - #[test] - fn parses_default_model_variant() { - let input = "default model: gpt-4o"; - let policy = parse(input).unwrap(); - assert_eq!(policy.default_model.as_deref(), Some("gpt-4o")); - } - - #[test] - fn rejects_unknown_period() { - let input = "limit all users $50/fortnight"; - let errs = parse(input).unwrap_err(); - assert!(matches!(&errs[0].kind, ParseErrorKind::InvalidPeriod(p) if p == "fortnight")); - } - - #[test] - fn rejects_invalid_amount() { - let input = "limit all users 50/week"; - let errs = parse(input).unwrap_err(); - assert_eq!(errs[0].kind, ParseErrorKind::InvalidSpendAmount); - } - - #[test] - fn rejects_empty_model() { - let input = "default:"; - let errs = parse(input).unwrap_err(); - assert_eq!(errs[0].kind, ParseErrorKind::EmptyModelId); - } - - #[test] - fn ignores_blank_lines_and_comments() { - let input = "\ -# workspace policy -limit all users $200/month - -default: gpt-4o"; - - let policy = parse(input).unwrap(); - assert_eq!(policy.spending_cap.unwrap().period, CapPeriod::Month); - assert_eq!(policy.default_model.as_deref(), Some("gpt-4o")); - } - - #[test] - fn rejects_per_user_limit_as_malformed() { - let input = "limit user@example.com $50/week"; - let errs = parse(input).unwrap_err(); - assert_eq!(errs[0].kind, ParseErrorKind::MalformedLine); - } - - #[test] - fn idempotent_reparse() { - let input = "limit all users $100/week\ndefault: gpt-4o\nrequestable: claude-4-6-sonnet"; - assert_eq!(parse(input), parse(input)); - } - - #[test] - fn all_or_nothing_on_mixed_error() { - let input = "limit all users $100/week\nbadline"; - assert!(parse(input).is_err()); - } + Ok(SpendingCap { + amount_cents, + period, + }) } diff --git a/module/iron_control_api/src/lib.rs b/module/iron_control_api/src/lib.rs index e84776f2..a3f239bf 100644 --- a/module/iron_control_api/src/lib.rs +++ b/module/iron_control_api/src/lib.rs @@ -208,7 +208,7 @@ pub mod ic_token; pub mod rate_limiter; #[cfg(feature = "enabled")] -/// Deterministic paste parsers for FreeForm onboarding surfaces. +/// Deterministic paste parsers for `FreeForm` onboarding surfaces. pub mod freeform; #[cfg(feature = "enabled")] diff --git a/module/iron_control_api/tests/freeform_invites_test.rs b/module/iron_control_api/tests/freeform_invites_test.rs new file mode 100644 index 00000000..ff0cfde6 --- /dev/null +++ b/module/iron_control_api/tests/freeform_invites_test.rs @@ -0,0 +1,72 @@ +//! Tests for `freeform::invites` paste-block parser. +use iron_control_api::freeform::invites; + +#[test] +fn parses_email_list() { + let input = "\ +alice@acmecorp.com +bob@acmecorp.com +carol@acmecorp.com"; + + let emails = invites::parse(input).unwrap(); + assert_eq!( + emails, + vec![ + "alice@acmecorp.com", + "bob@acmecorp.com", + "carol@acmecorp.com" + ] + ); +} + +#[test] +fn ignores_blank_lines_and_comments() { + let input = "\ +# team invites +alice@example.com + +bob@example.com"; + + let emails = invites::parse(input).unwrap(); + assert_eq!(emails.len(), 2); +} + +#[test] +fn deduplicates_emails_silently() { + let input = "alice@example.com\nAlice@example.com"; + let emails = invites::parse(input).unwrap(); + assert_eq!(emails, vec!["alice@example.com"]); +} + +#[test] +fn lowercases_output() { + let input = "Bob@EXAMPLE.COM"; + let emails = invites::parse(input).unwrap(); + assert_eq!(emails, vec!["bob@example.com"]); +} + +#[test] +fn rejects_invalid_line() { + let input = "notanemail"; + let errs = invites::parse(input).unwrap_err(); + assert_eq!(errs.len(), 1); + assert_eq!(errs[0].content, "notanemail"); +} + +#[test] +fn all_or_nothing_on_mixed_valid_invalid() { + let input = "alice@example.com\nnotanemail\nbob@example.com"; + assert!(invites::parse(input).is_err()); +} + +#[test] +fn empty_input_returns_empty_vec() { + let emails = invites::parse("").unwrap(); + assert!(emails.is_empty()); +} + +#[test] +fn idempotent_reparse_produces_same_result() { + let input = "alice@example.com\nbob@example.com"; + assert_eq!(invites::parse(input), invites::parse(input)); +} diff --git a/module/iron_control_api/tests/freeform_providers_test.rs b/module/iron_control_api/tests/freeform_providers_test.rs new file mode 100644 index 00000000..e6fddbc4 --- /dev/null +++ b/module/iron_control_api/tests/freeform_providers_test.rs @@ -0,0 +1,74 @@ +//! Tests for `freeform::providers` paste-block parser. +use iron_control_api::freeform::providers::{self, KnownProvider, LineErrorKind}; + +#[test] +fn parses_multiple_providers() { + let input = "\ +gemini: AIzaSy...xxxx +openai: sk-proj-...xxxx +anthropic: sk-ant-...xxxx"; + + let entries = providers::parse(input).unwrap(); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].provider, KnownProvider::Gemini); + assert_eq!(entries[1].provider, KnownProvider::OpenAi); + assert_eq!(entries[2].provider, KnownProvider::Anthropic); +} + +#[test] +fn ignores_blank_lines_and_comments() { + let input = "\ +# workspace bootstrap +openai: sk-proj-key + +# second provider +gemini: AIzaSy-key"; + + let entries = providers::parse(input).unwrap(); + assert_eq!(entries.len(), 2); +} + +#[test] +fn rejects_unknown_provider() { + let input = "groq: sk-groq-xxxx"; + let errs = providers::parse(input).unwrap_err(); + assert_eq!(errs.len(), 1); + assert!(matches!(&errs[0].kind, LineErrorKind::UnknownProvider(p) if p == "groq")); +} + +#[test] +fn rejects_wrong_key_prefix_for_provider() { + let input = "anthropic: sk-proj-wrong"; + let errs = providers::parse(input).unwrap_err(); + assert_eq!(errs.len(), 1); + assert_eq!(errs[0].kind, LineErrorKind::InvalidKeyPrefix); +} + +#[test] +fn rejects_line_without_colon() { + let input = "notaproviderline"; + let errs = providers::parse(input).unwrap_err(); + assert_eq!(errs.len(), 1); + assert_eq!(errs[0].kind, LineErrorKind::MalformedLine); +} + +#[test] +fn all_or_nothing_on_mixed_valid_invalid() { + let input = "openai: sk-proj-good\nbadprovider: key"; + let errs = providers::parse(input).unwrap_err(); + assert_eq!(errs.len(), 1); +} + +#[test] +fn empty_input_returns_empty_vec() { + let entries = providers::parse("").unwrap(); + assert!(entries.is_empty()); +} + +#[test] +fn idempotent_reparse_produces_same_result() { + let input = "openai: sk-proj-abc\ngemini: AIzaSy-xyz"; + let first = providers::parse(input).unwrap(); + let second = providers::parse(input).unwrap(); + assert_eq!(first, second); +} diff --git a/module/iron_control_api/tests/freeform_usage_policy_test.rs b/module/iron_control_api/tests/freeform_usage_policy_test.rs new file mode 100644 index 00000000..b81b4891 --- /dev/null +++ b/module/iron_control_api/tests/freeform_usage_policy_test.rs @@ -0,0 +1,85 @@ +//! Tests for `freeform::usage_policy` paste-block parser. +use iron_control_api::freeform::usage_policy::{self, CapPeriod, ParseErrorKind, SpendingCap}; + +#[test] +fn parses_multiline_policy() { + let input = "\ +limit all users $100/week +default: gpt-5.4-mini"; + + let policy = usage_policy::parse(input).unwrap(); + assert_eq!( + policy.spending_cap, + Some(SpendingCap { + amount_cents: 10000, + period: CapPeriod::Week + }) + ); + assert_eq!(policy.default_model.as_deref(), Some("gpt-5.4-mini")); +} + +#[test] +fn single_line_deck_format_is_rejected() { + let input = "limit all users $100/week - default: gpt-5.4-mini"; + assert!(usage_policy::parse(input).is_err()); +} + +#[test] +fn parses_default_model_variant() { + let input = "default model: gpt-4o"; + let policy = usage_policy::parse(input).unwrap(); + assert_eq!(policy.default_model.as_deref(), Some("gpt-4o")); +} + +#[test] +fn rejects_unknown_period() { + let input = "limit all users $50/fortnight"; + let errs = usage_policy::parse(input).unwrap_err(); + assert!(matches!(&errs[0].kind, ParseErrorKind::InvalidPeriod(p) if p == "fortnight")); +} + +#[test] +fn rejects_invalid_amount() { + let input = "limit all users 50/week"; + let errs = usage_policy::parse(input).unwrap_err(); + assert_eq!(errs[0].kind, ParseErrorKind::InvalidSpendAmount); +} + +#[test] +fn rejects_empty_model() { + let input = "default:"; + let errs = usage_policy::parse(input).unwrap_err(); + assert_eq!(errs[0].kind, ParseErrorKind::EmptyModelId); +} + +#[test] +fn ignores_blank_lines_and_comments() { + let input = "\ +# workspace policy +limit all users $200/month + +default: gpt-4o"; + + let policy = usage_policy::parse(input).unwrap(); + assert_eq!(policy.spending_cap.unwrap().period, CapPeriod::Month); + assert_eq!(policy.default_model.as_deref(), Some("gpt-4o")); +} + +#[test] +fn rejects_per_user_limit_as_malformed() { + let input = "limit user@example.com $50/week"; + let errs = usage_policy::parse(input).unwrap_err(); + assert_eq!(errs[0].kind, ParseErrorKind::MalformedLine); +} + +#[test] +fn idempotent_reparse() { + let input = "limit all users $100/week\ndefault: gpt-4o"; + assert_eq!(usage_policy::parse(input), usage_policy::parse(input)); +} + +#[test] +fn all_or_nothing_on_mixed_error() { + let input = "limit all users $100/week\nbadline"; + assert!(usage_policy::parse(input).is_err()); +} From ec0a6d2690285657e0d7844498aaf4c526d03aa3 Mon Sep 17 00:00:00 2001 From: Ivan Kinder Date: Thu, 14 May 2026 15:50:30 +0300 Subject: [PATCH 04/46] feat(freeform): add CSV-style parser for company setup --- .../src/freeform/company_setup.rs | 110 ++++++++++++++++++ module/iron_control_api/src/freeform/mod.rs | 2 + .../tests/freeform_company_setup_test.rs | 67 +++++++++++ 3 files changed, 179 insertions(+) create mode 100644 module/iron_control_api/src/freeform/company_setup.rs create mode 100644 module/iron_control_api/tests/freeform_company_setup_test.rs diff --git a/module/iron_control_api/src/freeform/company_setup.rs b/module/iron_control_api/src/freeform/company_setup.rs new file mode 100644 index 00000000..9b13da39 --- /dev/null +++ b/module/iron_control_api/src/freeform/company_setup.rs @@ -0,0 +1,110 @@ +use serde::{Deserialize, Serialize}; + +/// Workspace account category. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AccountType { + /// External client workspace. + Client, + /// Internal company workspace. + Internal, +} + +/// Result of a successful parse of a company-setup line. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ParsedCompany { + /// Workspace display name. + pub name: String, + /// Primary domain (e.g. `acme.com`). + pub domain: String, + /// Account category. + pub account_type: AccountType, +} + +/// Reason a company-setup line was rejected. +#[derive(Debug, PartialEq)] +pub enum ParseErrorKind { + /// The input does not contain exactly three comma-separated fields. + WrongFieldCount, + /// A required field was present but empty after trimming. + EmptyField(&'static str), + /// The account-type value is not `Client` or `Internal`. + UnknownAccountType(String), +} + +/// A parse error for a company-setup line. +#[derive(Debug, PartialEq)] +pub struct ParseError { + /// Reason the line was rejected. + pub kind: ParseErrorKind, +} + +impl core::fmt::Display for ParseError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let msg = match &self.kind { + ParseErrorKind::WrongFieldCount => { + "expected exactly three comma-separated fields: name, domain, account_type".to_string() + } + ParseErrorKind::EmptyField(name) => format!("field '{name}' must not be empty"), + ParseErrorKind::UnknownAccountType(v) => { + format!("unknown account type '{v}' — supported: Client, Internal") + } + }; + write!(f, "{msg}") + } +} + +/// Parses a single company-setup line in the form `Name, domain, AccountType`. +/// +/// All fields are comma-separated. Whitespace around commas is trimmed. +/// `AccountType` is case-insensitive (`client` and `Client` both accepted). +/// +/// # Errors +/// +/// Returns a [`ParseError`] if the line is malformed, a field is empty, +/// or the account type is not recognised. +pub fn parse(input: &str) -> Result { + let parts: Vec<&str> = input.splitn(3, ',').collect(); + + if parts.len() != 3 { + return Err(ParseError { + kind: ParseErrorKind::WrongFieldCount, + }); + } + + let name = parts[0].trim().to_string(); + let domain = parts[1].trim().to_string(); + let type_str = parts[2].trim(); + + if name.is_empty() { + return Err(ParseError { + kind: ParseErrorKind::EmptyField("name"), + }); + } + if domain.is_empty() { + return Err(ParseError { + kind: ParseErrorKind::EmptyField("domain"), + }); + } + if type_str.is_empty() { + return Err(ParseError { + kind: ParseErrorKind::EmptyField("account_type"), + }); + } + + let account_type = match type_str.to_lowercase().as_str() { + "client" => AccountType::Client, + "internal" => AccountType::Internal, + _ => { + return Err(ParseError { + kind: ParseErrorKind::UnknownAccountType(type_str.to_string()), + }) + } + }; + + Ok(ParsedCompany { + name, + domain, + account_type, + }) +} diff --git a/module/iron_control_api/src/freeform/mod.rs b/module/iron_control_api/src/freeform/mod.rs index 036c46ff..87da7cad 100644 --- a/module/iron_control_api/src/freeform/mod.rs +++ b/module/iron_control_api/src/freeform/mod.rs @@ -1,3 +1,5 @@ +/// Parser for company-setup CSV lines. +pub mod company_setup; /// Parser for invite email-address paste blocks. pub mod invites; /// Parser for `provider: key` paste blocks. diff --git a/module/iron_control_api/tests/freeform_company_setup_test.rs b/module/iron_control_api/tests/freeform_company_setup_test.rs new file mode 100644 index 00000000..37f42760 --- /dev/null +++ b/module/iron_control_api/tests/freeform_company_setup_test.rs @@ -0,0 +1,67 @@ +//! Tests for `freeform::company_setup` CSV-line parser. +use iron_control_api::freeform::company_setup::{self, AccountType, ParseErrorKind}; + +#[test] +fn parses_client_company() { + let result = company_setup::parse("Acme Corp, acme.com, Client").unwrap(); + assert_eq!(result.name, "Acme Corp"); + assert_eq!(result.domain, "acme.com"); + assert_eq!(result.account_type, AccountType::Client); +} + +#[test] +fn parses_internal_company() { + let result = company_setup::parse("Iron Cage, ironcage.io, Internal").unwrap(); + assert_eq!(result.account_type, AccountType::Internal); +} + +#[test] +fn trims_whitespace_around_commas() { + let result = company_setup::parse(" Acme Corp , acme.com , Client ").unwrap(); + assert_eq!(result.name, "Acme Corp"); + assert_eq!(result.domain, "acme.com"); +} + +#[test] +fn account_type_is_case_insensitive() { + assert!(company_setup::parse("Acme, acme.com, client").is_ok()); + assert!(company_setup::parse("Acme, acme.com, INTERNAL").is_ok()); +} + +#[test] +fn name_may_contain_commas_if_only_two_splits_used() { + // splitn(3) keeps everything after the second comma in the third field — this should fail + // because "Client, Extra" is not a valid account type. + let err = company_setup::parse("Acme, Corp, acme.com, Client").unwrap_err(); + assert!(matches!(err.kind, ParseErrorKind::UnknownAccountType(_))); +} + +#[test] +fn rejects_missing_fields() { + let err = company_setup::parse("Acme, acme.com").unwrap_err(); + assert_eq!(err.kind, ParseErrorKind::WrongFieldCount); +} + +#[test] +fn rejects_empty_name() { + let err = company_setup::parse(", acme.com, Client").unwrap_err(); + assert_eq!(err.kind, ParseErrorKind::EmptyField("name")); +} + +#[test] +fn rejects_empty_domain() { + let err = company_setup::parse("Acme Corp, , Client").unwrap_err(); + assert_eq!(err.kind, ParseErrorKind::EmptyField("domain")); +} + +#[test] +fn rejects_unknown_account_type() { + let err = company_setup::parse("Acme Corp, acme.com, Partner").unwrap_err(); + assert!(matches!(err.kind, ParseErrorKind::UnknownAccountType(v) if v == "Partner")); +} + +#[test] +fn idempotent_reparse() { + let input = "Acme Corp, acme.com, Client"; + assert_eq!(company_setup::parse(input), company_setup::parse(input)); +} From f6e25371d73ef4415f8b3829c1c6231dbbabb45b Mon Sep 17 00:00:00 2001 From: Ivan Kinder Date: Thu, 14 May 2026 15:55:31 +0300 Subject: [PATCH 05/46] feat(db): add migrations for workspace_policy, invite_links and invite_seats --- .../031_add_workspaces_and_policy.sql | 23 +++++++++++++++++++ .../migrations/032_add_invite_links.sql | 21 +++++++++++++++++ .../migrations/033_add_invite_seats.sql | 19 +++++++++++++++ module/iron_token_manager/src/migrations.rs | 3 +++ 4 files changed, 66 insertions(+) create mode 100644 module/iron_token_manager/migrations/031_add_workspaces_and_policy.sql create mode 100644 module/iron_token_manager/migrations/032_add_invite_links.sql create mode 100644 module/iron_token_manager/migrations/033_add_invite_seats.sql diff --git a/module/iron_token_manager/migrations/031_add_workspaces_and_policy.sql b/module/iron_token_manager/migrations/031_add_workspaces_and_policy.sql new file mode 100644 index 00000000..aee90b19 --- /dev/null +++ b/module/iron_token_manager/migrations/031_add_workspaces_and_policy.sql @@ -0,0 +1,23 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS workspaces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + domain TEXT NOT NULL, + account_type TEXT NOT NULL CHECK (account_type IN ('client', 'internal')), + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000) +); + +-- One policy row per workspace; workspace_id doubles as primary key. +CREATE TABLE IF NOT EXISTS workspace_policy ( + workspace_id INTEGER PRIMARY KEY REFERENCES workspaces(id), + budget_amount_cents INTEGER NOT NULL CHECK (budget_amount_cents >= 0), + budget_period TEXT NOT NULL CHECK (budget_period IN ('day', 'week', 'month')), + default_model TEXT, + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000) +); + +CREATE TABLE IF NOT EXISTS _migration_031_completed (applied_at INTEGER NOT NULL); +INSERT INTO _migration_031_completed (applied_at) VALUES (strftime('%s', 'now') * 1000); + +COMMIT; diff --git a/module/iron_token_manager/migrations/032_add_invite_links.sql b/module/iron_token_manager/migrations/032_add_invite_links.sql new file mode 100644 index 00000000..501daa62 --- /dev/null +++ b/module/iron_token_manager/migrations/032_add_invite_links.sql @@ -0,0 +1,21 @@ +BEGIN; + +-- One row per generated invite link. +-- token_hash stores SHA-256 of the raw token; raw value is returned once at creation. +CREATE TABLE IF NOT EXISTS invite_links ( + id TEXT PRIMARY KEY, + workspace_id INTEGER NOT NULL REFERENCES workspaces(id), + token_hash TEXT NOT NULL UNIQUE, + seats_total INTEGER NOT NULL DEFAULT 1 CHECK (seats_total >= 1), + seats_used INTEGER NOT NULL DEFAULT 0 CHECK (seats_used >= 0), + expires_at INTEGER, + created_by TEXT NOT NULL REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_invite_links_token_hash ON invite_links (token_hash); +CREATE INDEX IF NOT EXISTS idx_invite_links_workspace ON invite_links (workspace_id); + +CREATE TABLE IF NOT EXISTS _migration_032_completed (applied_at INTEGER NOT NULL); +INSERT INTO _migration_032_completed (applied_at) VALUES (strftime('%s', 'now') * 1000); + +COMMIT; diff --git a/module/iron_token_manager/migrations/033_add_invite_seats.sql b/module/iron_token_manager/migrations/033_add_invite_seats.sql new file mode 100644 index 00000000..051aed60 --- /dev/null +++ b/module/iron_token_manager/migrations/033_add_invite_seats.sql @@ -0,0 +1,19 @@ +BEGIN; + +-- One row per invite acceptance; user_id is NULL until the member registers. +CREATE TABLE IF NOT EXISTS invite_seats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invite_link_id TEXT NOT NULL REFERENCES invite_links(id), + user_id TEXT REFERENCES users(id), + accepted_at INTEGER, + approved_at INTEGER, + approved_by TEXT REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_invite_seats_link ON invite_seats (invite_link_id); +CREATE INDEX IF NOT EXISTS idx_invite_seats_user ON invite_seats (user_id); + +CREATE TABLE IF NOT EXISTS _migration_033_completed (applied_at INTEGER NOT NULL); +INSERT INTO _migration_033_completed (applied_at) VALUES (strftime('%s', 'now') * 1000); + +COMMIT; diff --git a/module/iron_token_manager/src/migrations.rs b/module/iron_token_manager/src/migrations.rs index 1e6a2ee1..e363d8d5 100644 --- a/module/iron_token_manager/src/migrations.rs +++ b/module/iron_token_manager/src/migrations.rs @@ -85,6 +85,9 @@ static MIGRATIONS: &[(&str, &str)] = &[ migration!("028_add_spending_used_non_negative_guard"), migration!("029_add_spending_constraints"), migration!("030_add_agent_token_index"), + migration!("031_add_workspaces_and_policy"), + migration!("032_add_invite_links"), + migration!("033_add_invite_seats"), ]; /// Applies all migrations to the database pool. From a77c6bc341b0c6487bafe4b45dd130805a066e39 Mon Sep 17 00:00:00 2001 From: Ivan Kinder Date: Thu, 14 May 2026 16:16:03 +0300 Subject: [PATCH 06/46] feat(api): add POST /freeform/company and POST /freeform/providers endpoints --- .../src/bin/iron_control_api_server.rs | 39 ++- .../iron_control_api/src/routes/freeform.rs | 238 ++++++++++++++++++ module/iron_control_api/src/routes/mod.rs | 1 + 3 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 module/iron_control_api/src/routes/freeform.rs diff --git a/module/iron_control_api/src/bin/iron_control_api_server.rs b/module/iron_control_api/src/bin/iron_control_api_server.rs index 2ddace28..f0182fef 100644 --- a/module/iron_control_api/src/bin/iron_control_api_server.rs +++ b/module/iron_control_api/src/bin/iron_control_api_server.rs @@ -70,9 +70,9 @@ use iron_control_api::{ ic_token::{IcTokenManager, IcTokenRateLimiter}, rbac::PermissionChecker, routes::{ - self, analytics::AnalyticsState, auth::AuthState, budget::BudgetState, ic_token::IcTokenState, - keys::KeysState, limits::LimitsState, providers::ProvidersState, tokens::TokenState, - usage::UsageState, users::UserManagementState, + self, analytics::AnalyticsState, auth::AuthState, budget::BudgetState, freeform::FreeformState, + ic_token::IcTokenState, keys::KeysState, limits::LimitsState, providers::ProvidersState, + tokens::TokenState, usage::UsageState, users::UserManagementState, }, token_auth::ApiTokenState, }; @@ -216,6 +216,7 @@ struct AppState { providers: ProvidersState, keys: KeysState, users: UserManagementState, + freeform: FreeformState, agents: SqlitePool, budget: BudgetState, analytics: AnalyticsState, @@ -278,6 +279,13 @@ impl FromRef for UserManagementState { } } +/// Enable freeform routes to access `FreeformState` from combined `AppState` +impl FromRef for FreeformState { + fn from_ref(state: &AppState) -> Self { + state.freeform.clone() + } +} + /// Enable agent routes to access `SqlitePool` from combined `AppState` impl FromRef for SqlitePool { fn from_ref(state: &AppState) -> Self { @@ -447,7 +455,11 @@ async fn main() -> Result<(), Box> { // agent_1 during handshake — a development convenience that must never // be active in production as it bypasses the key-assignment requirement // and could expose unguarded API key paths. - if env::var("IRON_ALLOW_DEV_KEYS").ok().filter(|v| v != "0" && v != "false" && !v.is_empty()).is_some() { + if env::var("IRON_ALLOW_DEV_KEYS") + .ok() + .filter(|v| v != "0" && v != "false" && !v.is_empty()) + .is_some() + { tracing::error!( "[CRITICAL] CRITICAL: IRON_ALLOW_DEV_KEYS is set in a production environment" ); @@ -550,6 +562,8 @@ async fn main() -> Result<(), Box> { // Clone crypto_service for BudgetState (Feature 014: Agent Provider Key) let crypto_service_for_budget = crypto_service.clone(); + // Clone crypto_service for FreeformState (provider key encryption during onboarding) + let crypto_service_for_freeform = crypto_service.clone(); let keys_state = KeysState { token_storage: token_state.storage.clone(), @@ -591,6 +605,13 @@ async fn main() -> Result<(), Box> { ic_token_manager: ic_token_manager.clone(), }; + // Initialize FreeForm onboarding state + let freeform_state = FreeformState { + pool: agents_pool.clone(), + provider_storage: providers_state.storage.clone(), + crypto: Some(crypto_service_for_freeform), + }; + // Seed database with test data if empty (development convenience) let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") .fetch_one(&agents_pool) @@ -632,6 +653,7 @@ async fn main() -> Result<(), Box> { providers: providers_state, keys: keys_state, users: user_management_state, + freeform: freeform_state, agents: agents_pool, budget: budget_state, analytics: analytics_state, @@ -756,6 +778,15 @@ async fn main() -> Result<(), Box> { "/api/v1/projects/{project_id}/provider", delete(routes::providers::unassign_provider_from_project), ) + // FreeForm onboarding endpoints (Admin) + .route( + "/api/v1/freeform/company", + post(routes::freeform::post_company), + ) + .route( + "/api/v1/freeform/providers", + post(routes::freeform::post_providers), + ) // Key fetch endpoint (API token authentication) .route("/api/v1/keys", get(routes::keys::get_key)) // Agent management endpoints diff --git a/module/iron_control_api/src/routes/freeform.rs b/module/iron_control_api/src/routes/freeform.rs new file mode 100644 index 00000000..550d4e3d --- /dev/null +++ b/module/iron_control_api/src/routes/freeform.rs @@ -0,0 +1,238 @@ +//! `FreeForm` onboarding API endpoints +//! +//! Endpoints: +//! - POST `/api/v1/freeform/company` — parse + upsert workspace (Admin) +//! - POST `/api/v1/freeform/providers` — parse + apply provider keys (Admin) + +use core::fmt::{Debug, Formatter, Result as FmtResult}; +use core::str::FromStr; +use std::sync::Arc; + +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Json}, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{Pool, Sqlite}; + +use crate::{ + error::{ApiError, ApiResult, JsonBody}, + freeform::{ + company_setup::{self, AccountType}, + providers, + }, + jwt_auth::AuthenticatedUser, + rbac::{Permission, PermissionChecker, Role}, +}; +use iron_secrets::crypto::CryptoService; +use iron_token_manager::provider_key_storage::{CreateKeyParams, ProviderKeyStorage, ProviderType}; + +/// State for `FreeForm` onboarding endpoints. +#[derive(Clone)] +pub struct FreeformState { + /// Database pool for workspace operations. + pub pool: Pool, + /// Provider key storage shared with the providers route. + pub provider_storage: Arc, + /// Crypto service for key encryption. `None` if `IRON_SECRETS_MASTER_KEY` not set. + pub crypto: Option>, +} + +impl Debug for FreeformState { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + f.debug_struct("FreeformState") + .field("pool", &"") + .field("provider_storage", &"") + .field("crypto", &self.crypto.as_ref().map(|_| "")) + .finish() + } +} + +/// Request body for both freeform endpoints: plain text paste. +#[derive(Debug, Deserialize)] +pub struct TextRequest { + /// The raw paste text to parse. + pub text: String, +} + +/// Response from `POST /api/v1/freeform/company`. +#[derive(Debug, Serialize)] +pub struct WorkspaceResponse { + /// Database ID of the upserted workspace. + pub id: i64, + /// Workspace display name. + pub name: String, + /// Primary domain. + pub domain: String, + /// Account type string (`client` or `internal`). + pub account_type: String, +} + +/// One entry in the providers apply response. +#[derive(Debug, Serialize)] +pub struct AppliedProvider { + /// Provider name string. + pub provider: String, + /// Database ID of the newly created key. + pub key_id: i64, +} + +/// Response from `POST /api/v1/freeform/providers`. +#[derive(Debug, Serialize)] +pub struct ApplyProvidersResponse { + /// List of successfully applied provider keys. + pub applied: Vec, +} + +fn check_permission(role_str: &str, permission: Permission) -> Result<(), ApiError> { + let role = Role::from_str(role_str) + .map_err(|_| ApiError::Forbidden(format!("Invalid role: {role_str}")))?; + let checker = PermissionChecker::new(); + if checker.has_permission(role, permission) { + Ok(()) + } else { + Err(ApiError::Forbidden(format!( + "Permission {permission:?} required" + ))) + } +} + +fn account_type_to_str(t: &AccountType) -> &'static str { + match t { + AccountType::Client => "client", + AccountType::Internal => "internal", + } +} + +fn map_known_provider(known: &providers::KnownProvider) -> Result { + match known { + providers::KnownProvider::Gemini => Ok(ProviderType::Gemini), + providers::KnownProvider::OpenAi => Ok(ProviderType::OpenAI), + providers::KnownProvider::Anthropic => Ok(ProviderType::Anthropic), + providers::KnownProvider::Mistral | providers::KnownProvider::Cohere => { + Err(ApiError::BadRequest(format!( + "Provider '{known:?}' is not yet supported in key storage" + ))) + } + } +} + +/// Maximum provider keys per user per provider (shared with providers route). +const MAX_KEYS_PER_USER_PER_PROVIDER: i64 = 20; + +/// POST /api/v1/freeform/company +/// +/// Parse a company-setup line and upsert the workspace record. +/// +/// # Errors +/// +/// Returns `ApiError` on permission failure, parse error, or database error. +pub async fn post_company( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + JsonBody(body): JsonBody, +) -> ApiResult { + check_permission(&claims.role, Permission::ManageUsers)?; + + let company = + company_setup::parse(&body.text).map_err(|e| ApiError::BadRequest(e.to_string()))?; + + let account_type_str = account_type_to_str(&company.account_type); + + let id: i64 = sqlx::query_scalar( + r" + INSERT INTO workspaces (id, name, domain, account_type, created_at) + VALUES (1, ?, ?, ?, strftime('%s', 'now') * 1000) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + domain = excluded.domain, + account_type = excluded.account_type + RETURNING id + ", + ) + .bind(&company.name) + .bind(&company.domain) + .bind(account_type_str) + .fetch_one(&state.pool) + .await + .map_err(|_| ApiError::Internal("Failed to upsert workspace".into()))?; + + Ok(( + StatusCode::OK, + Json(WorkspaceResponse { + id, + name: company.name, + domain: company.domain, + account_type: account_type_str.to_string(), + }), + )) +} + +/// POST /api/v1/freeform/providers +/// +/// Parse a provider-key block and apply all keys transactionally. +/// Returns the list of applied provider names and their new key IDs. +/// +/// # Errors +/// +/// Returns `ApiError` on permission failure, parse error, crypto unavailability, +/// or database error. +pub async fn post_providers( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + JsonBody(body): JsonBody, +) -> ApiResult { + check_permission(&claims.role, Permission::ManageProviderKeys)?; + + let crypto = state.crypto.as_ref().ok_or_else(|| { + ApiError::ServiceUnavailable( + "Provider key encryption is disabled. Set IRON_SECRETS_MASTER_KEY to enable.".into(), + ) + })?; + + let entries = providers::parse(&body.text).map_err(|errors| { + let msg = errors + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join("; "); + ApiError::BadRequest(msg) + })?; + + let mut applied: Vec = Vec::with_capacity(entries.len()); + + for entry in &entries { + let provider_type = map_known_provider(&entry.provider)?; + let encrypted = crypto + .encrypt(&entry.key) + .map_err(|_| ApiError::Internal("Failed to encrypt provider key".into()))?; + + let key_id = state + .provider_storage + .create_key_within_quota(&CreateKeyParams { + provider: provider_type, + encrypted_api_key: &encrypted.ciphertext_base64(), + encryption_nonce: &encrypted.nonce_base64(), + base_url: None, + description: Some("Added via FreeForm onboarding"), + user_id: &claims.sub, + max_keys: MAX_KEYS_PER_USER_PER_PROVIDER, + }) + .await + .map_err(|e| { + if matches!(e, iron_token_manager::error::TokenError::KeyQuotaExceeded) { + ApiError::TooManyRequests(format!("Quota exceeded for provider {:?}", entry.provider)) + } else { + ApiError::Internal("Failed to store provider key".into()) + } + })?; + + applied.push(AppliedProvider { + provider: provider_type.to_string(), + key_id, + }); + } + + Ok((StatusCode::OK, Json(ApplyProvidersResponse { applied }))) +} diff --git a/module/iron_control_api/src/routes/mod.rs b/module/iron_control_api/src/routes/mod.rs index 4fb21d95..bf5dabe0 100644 --- a/module/iron_control_api/src/routes/mod.rs +++ b/module/iron_control_api/src/routes/mod.rs @@ -7,6 +7,7 @@ pub mod agents; pub mod analytics; pub mod auth; pub mod budget; +pub mod freeform; pub mod health; pub mod ic_token; pub mod keys; From 0ac66c5a1d4e279ce3c625962e74da5354440fee Mon Sep 17 00:00:00 2001 From: Ivan Kinder Date: Thu, 14 May 2026 16:25:48 +0300 Subject: [PATCH 07/46] feat(api): add POST /workspace/budget endpoint --- .../src/bin/iron_control_api_server.rs | 5 + module/iron_control_api/src/routes/mod.rs | 1 + .../iron_control_api/src/routes/workspace.rs | 159 +++++++++ .../tests/workspace_budget_test.rs | 324 ++++++++++++++++++ 4 files changed, 489 insertions(+) create mode 100644 module/iron_control_api/src/routes/workspace.rs create mode 100644 module/iron_control_api/tests/workspace_budget_test.rs diff --git a/module/iron_control_api/src/bin/iron_control_api_server.rs b/module/iron_control_api/src/bin/iron_control_api_server.rs index f0182fef..df857c08 100644 --- a/module/iron_control_api/src/bin/iron_control_api_server.rs +++ b/module/iron_control_api/src/bin/iron_control_api_server.rs @@ -787,6 +787,11 @@ async fn main() -> Result<(), Box> { "/api/v1/freeform/providers", post(routes::freeform::post_providers), ) + // Workspace management endpoints (Admin) + .route( + "/api/v1/workspace/budget", + post(routes::workspace::post_workspace_budget), + ) // Key fetch endpoint (API token authentication) .route("/api/v1/keys", get(routes::keys::get_key)) // Agent management endpoints diff --git a/module/iron_control_api/src/routes/mod.rs b/module/iron_control_api/src/routes/mod.rs index bf5dabe0..a3855a51 100644 --- a/module/iron_control_api/src/routes/mod.rs +++ b/module/iron_control_api/src/routes/mod.rs @@ -17,3 +17,4 @@ pub mod tokens; pub mod usage; pub mod users; pub mod version; +pub mod workspace; diff --git a/module/iron_control_api/src/routes/workspace.rs b/module/iron_control_api/src/routes/workspace.rs new file mode 100644 index 00000000..724d1657 --- /dev/null +++ b/module/iron_control_api/src/routes/workspace.rs @@ -0,0 +1,159 @@ +//! Workspace management API endpoints +//! +//! Endpoints: +//! - POST `/api/v1/workspace/budget` — upsert workspace spending policy (Admin) + +use core::str::FromStr; + +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Json}, +}; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; + +use crate::{ + error::{ApiError, ApiResult, JsonBody}, + freeform::usage_policy::{self, CapPeriod}, + jwt_auth::AuthenticatedUser, + rbac::{Permission, PermissionChecker, Role}, +}; + +fn check_permission(role_str: &str, permission: Permission) -> Result<(), ApiError> { + let role = Role::from_str(role_str) + .map_err(|_| ApiError::Forbidden(format!("Invalid role: {role_str}")))?; + let checker = PermissionChecker::new(); + if checker.has_permission(role, permission) { + Ok(()) + } else { + Err(ApiError::Forbidden(format!( + "Permission {permission:?} required" + ))) + } +} + +fn period_to_str(period: &CapPeriod) -> &'static str { + match period { + CapPeriod::Day => "day", + CapPeriod::Week => "week", + CapPeriod::Month => "month", + } +} + +/// Request body for `POST /api/v1/workspace/budget`. +/// +/// Accepts either a raw freeform string or structured fields. +/// If `text` is provided, it is parsed via the usage-policy parser. +/// Otherwise `amount_cents` and `period` must both be present. +#[derive(Debug, Deserialize)] +pub struct BudgetRequest { + /// Raw freeform string e.g. `"limit all users $100/week"` or `"$100/week"`. + pub text: Option, + /// Budget ceiling in US cents (e.g. `$100` -> `10000`). Used when `text` is absent. + pub amount_cents: Option, + /// Billing period: `"day"`, `"week"`, or `"month"`. Used when `text` is absent. + pub period: Option, +} + +/// Response from `POST /api/v1/workspace/budget`. +#[derive(Debug, Serialize)] +pub struct WorkspaceBudgetResponse { + /// Workspace the policy applies to (always `1` for single-workspace deployments). + pub workspace_id: i64, + /// Effective budget ceiling in US cents. + pub amount_cents: i64, + /// Effective billing period. + pub period: String, +} + +/// POST /api/v1/workspace/budget +/// +/// Upsert the workspace spending policy. Accepts either a raw freeform string +/// or structured `amount_cents` + `period` fields. +/// +/// # Errors +/// +/// Returns `ApiError` on permission failure, parse error, missing fields, +/// or database error. +pub async fn post_workspace_budget( + State(pool): State, + AuthenticatedUser(claims): AuthenticatedUser, + JsonBody(body): JsonBody, +) -> ApiResult { + check_permission(&claims.role, Permission::ManageUsers)?; + + let (amount_cents, period_str) = if let Some(ref raw) = body.text { + let policy = usage_policy::parse(raw).map_err(|errors| { + let msg = errors + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join("; "); + ApiError::BadRequest(msg) + })?; + + let cap = policy.spending_cap.ok_or_else(|| { + ApiError::BadRequest( + "No spending cap found in text. Expected: 'limit all users $N/'".into(), + ) + })?; + + let cents: i64 = cap + .amount_cents + .try_into() + .map_err(|_| ApiError::BadRequest("Budget amount too large".into()))?; + + (cents, period_to_str(&cap.period).to_string()) + } else { + let amount = body.amount_cents.ok_or_else(|| { + ApiError::BadRequest("Either 'text' or 'amount_cents' + 'period' required".into()) + })?; + let period = body.period.ok_or_else(|| { + ApiError::BadRequest("Either 'text' or 'amount_cents' + 'period' required".into()) + })?; + + let valid_period = match period.to_lowercase().as_str() { + "day" | "week" | "month" => period.to_lowercase(), + _ => { + return Err(ApiError::BadRequest(format!( + "Invalid period '{period}' - supported: day, week, month" + ))) + } + }; + + if amount < 0 { + return Err(ApiError::BadRequest( + "amount_cents must be non-negative".into(), + )); + } + + (amount, valid_period) + }; + + let workspace_id: i64 = sqlx::query_scalar( + r" + INSERT INTO workspace_policy (workspace_id, budget_amount_cents, budget_period, updated_at) + VALUES (1, ?, ?, strftime('%s', 'now') * 1000) + ON CONFLICT(workspace_id) DO UPDATE SET + budget_amount_cents = excluded.budget_amount_cents, + budget_period = excluded.budget_period, + updated_at = excluded.updated_at + RETURNING workspace_id + ", + ) + .bind(amount_cents) + .bind(&period_str) + .fetch_one(&pool) + .await + .map_err(|_| ApiError::Internal("Failed to upsert workspace policy".into()))?; + + Ok(( + StatusCode::OK, + Json(WorkspaceBudgetResponse { + workspace_id, + amount_cents, + period: period_str, + }), + )) +} diff --git a/module/iron_control_api/tests/workspace_budget_test.rs b/module/iron_control_api/tests/workspace_budget_test.rs new file mode 100644 index 00000000..b5e94790 --- /dev/null +++ b/module/iron_control_api/tests/workspace_budget_test.rs @@ -0,0 +1,324 @@ +//! Integration tests for POST /api/v1/workspace/budget endpoint. + +#![allow(missing_docs)] + +mod common; + +use axum::{ + body::Body, + http::{Method, Request, StatusCode}, + routing::post, + Router, +}; +use common::budget::setup_test_db; +use iron_control_api::{ + jwt_auth::JwtSecret, + routes::{auth::AuthState, workspace::post_workspace_budget}, +}; +use serde_json::json; +use tower::ServiceExt; + +const TEST_JWT_SECRET: &str = "test_jwt_secret_workspace_budget_99"; + +#[derive(Clone)] +struct TestState { + pool: sqlx::SqlitePool, + auth: AuthState, +} + +impl axum::extract::FromRef for sqlx::SqlitePool { + fn from_ref(s: &TestState) -> Self { + s.pool.clone() + } +} + +impl axum::extract::FromRef for AuthState { + fn from_ref(s: &TestState) -> Self { + s.auth.clone() + } +} + +async fn setup() -> (TestState, String) { + let pool = setup_test_db().await; + + sqlx::query( + r" + INSERT OR IGNORE INTO workspaces (id, name, domain, account_type) + VALUES (1, 'Test Workspace', 'test.example.com', 'client') + ", + ) + .execute(&pool) + .await + .expect("LOUD FAILURE: Failed to seed workspace"); + + let auth = AuthState::new(TEST_JWT_SECRET.to_string(), "sqlite::memory:", false) + .await + .expect("LOUD FAILURE: Failed to create test AuthState"); + + let jwt = JwtSecret::new(TEST_JWT_SECRET.to_string()); + let token = jwt + .generate_access_token("user_1", "admin@test.com", "admin", "tok_001") + .expect("LOUD FAILURE: Failed to generate test JWT"); + let bearer = format!("Bearer {token}"); + + (TestState { pool, auth }, bearer) +} + +fn build_router(state: TestState) -> Router { + Router::new() + .route("/api/v1/workspace/budget", post(post_workspace_budget)) + .with_state(state) +} + +// Structured input ------------------------------------------------------------ + +#[tokio::test] +async fn test_structured_input_ok() { + let (state, bearer) = setup().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/workspace/budget") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from( + json!({"amount_cents": 10000, "period": "week"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = + serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 1024).await.unwrap()).unwrap(); + assert_eq!(body["workspace_id"], 1); + assert_eq!(body["amount_cents"], 10000); + assert_eq!(body["period"], "week"); +} + +#[tokio::test] +async fn test_structured_upsert_overwrites() { + let (state, bearer_1) = setup().await; + let app1 = build_router(state.clone()); + let app2 = build_router(state); + + let jwt = JwtSecret::new(TEST_JWT_SECRET.to_string()); + let token = jwt + .generate_access_token("user_1", "admin@test.com", "admin", "tok_002") + .unwrap(); + let bearer_2 = format!("Bearer {token}"); + + let _ = app1 + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/workspace/budget") + .header("Content-Type", "application/json") + .header("Authorization", bearer_1) + .body(Body::from( + json!({"amount_cents": 5000, "period": "day"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + let resp = app2 + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/workspace/budget") + .header("Content-Type", "application/json") + .header("Authorization", bearer_2) + .body(Body::from( + json!({"amount_cents": 20000, "period": "month"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = + serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 1024).await.unwrap()).unwrap(); + assert_eq!(body["amount_cents"], 20000); + assert_eq!(body["period"], "month"); +} + +// Text (freeform) input ------------------------------------------------------- + +#[tokio::test] +async fn test_text_input_ok() { + let (state, bearer) = setup().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/workspace/budget") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from( + json!({"text": "limit all users $250/month"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = + serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 1024).await.unwrap()).unwrap(); + assert_eq!(body["amount_cents"], 25000); + assert_eq!(body["period"], "month"); +} + +#[tokio::test] +async fn test_text_input_no_cap_returns_400() { + let (state, bearer) = setup().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/workspace/budget") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from( + json!({"text": "default: claude-3-5-sonnet-20241022"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +// Validation errors ----------------------------------------------------------- + +#[tokio::test] +async fn test_missing_both_inputs_returns_400() { + let (state, bearer) = setup().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/workspace/budget") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from(json!({}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_invalid_period_returns_400() { + let (state, bearer) = setup().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/workspace/budget") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from( + json!({"amount_cents": 1000, "period": "year"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_negative_amount_returns_400() { + let (state, bearer) = setup().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/workspace/budget") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from( + json!({"amount_cents": -100, "period": "week"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +// RBAC ----------------------------------------------------------------------- + +#[tokio::test] +async fn test_non_admin_returns_403() { + let (state, _) = setup().await; + let app = build_router(state); + + let jwt = JwtSecret::new(TEST_JWT_SECRET.to_string()); + let token = jwt + .generate_access_token("dev_user", "dev@test.com", "developer", "tok_dev") + .unwrap(); + let bearer = format!("Bearer {token}"); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/workspace/budget") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from( + json!({"amount_cents": 5000, "period": "week"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn test_unauthenticated_returns_401() { + let (state, _) = setup().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/workspace/budget") + .header("Content-Type", "application/json") + .body(Body::from( + json!({"amount_cents": 5000, "period": "week"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} From b32f5a33a1e129b82bb59d989a95b6bad6873698 Mon Sep 17 00:00:00 2001 From: Ivan Kinder Date: Thu, 14 May 2026 16:48:51 +0300 Subject: [PATCH 08/46] feat(api): add invite endpoints --- .../src/bin/iron_control_api_server.rs | 36 +- module/iron_control_api/src/routes/invites.rs | 422 ++++++++++++++ module/iron_control_api/src/routes/mod.rs | 1 + .../tests/invites_api_test.rs | 530 ++++++++++++++++++ 4 files changed, 987 insertions(+), 2 deletions(-) create mode 100644 module/iron_control_api/src/routes/invites.rs create mode 100644 module/iron_control_api/tests/invites_api_test.rs diff --git a/module/iron_control_api/src/bin/iron_control_api_server.rs b/module/iron_control_api/src/bin/iron_control_api_server.rs index df857c08..0e649903 100644 --- a/module/iron_control_api/src/bin/iron_control_api_server.rs +++ b/module/iron_control_api/src/bin/iron_control_api_server.rs @@ -71,8 +71,8 @@ use iron_control_api::{ rbac::PermissionChecker, routes::{ self, analytics::AnalyticsState, auth::AuthState, budget::BudgetState, freeform::FreeformState, - ic_token::IcTokenState, keys::KeysState, limits::LimitsState, providers::ProvidersState, - tokens::TokenState, usage::UsageState, users::UserManagementState, + ic_token::IcTokenState, invites::InviteState, keys::KeysState, limits::LimitsState, + providers::ProvidersState, tokens::TokenState, usage::UsageState, users::UserManagementState, }, token_auth::ApiTokenState, }; @@ -217,6 +217,7 @@ struct AppState { keys: KeysState, users: UserManagementState, freeform: FreeformState, + invites: InviteState, agents: SqlitePool, budget: BudgetState, analytics: AnalyticsState, @@ -286,6 +287,13 @@ impl FromRef for FreeformState { } } +/// Enable invite routes to access `InviteState` from combined `AppState` +impl FromRef for InviteState { + fn from_ref(state: &AppState) -> Self { + state.invites.clone() + } +} + /// Enable agent routes to access `SqlitePool` from combined `AppState` impl FromRef for SqlitePool { fn from_ref(state: &AppState) -> Self { @@ -612,6 +620,12 @@ async fn main() -> Result<(), Box> { crypto: Some(crypto_service_for_freeform), }; + // Initialize invite link state + let invite_state = InviteState { + pool: agents_pool.clone(), + jwt_secret: auth_state.jwt_secret.clone(), + }; + // Seed database with test data if empty (development convenience) let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") .fetch_one(&agents_pool) @@ -654,6 +668,7 @@ async fn main() -> Result<(), Box> { keys: keys_state, users: user_management_state, freeform: freeform_state, + invites: invite_state, agents: agents_pool, budget: budget_state, analytics: analytics_state, @@ -792,6 +807,23 @@ async fn main() -> Result<(), Box> { "/api/v1/workspace/budget", post(routes::workspace::post_workspace_budget), ) + // Invite link endpoints + .route( + "/api/v1/invites/generate", + post(routes::invites::post_invite_generate), + ) + .route( + "/api/v1/invites/{token}", + get(routes::invites::get_invite_preview), + ) + .route( + "/api/v1/invites/{token}/accept", + post(routes::invites::post_invite_accept), + ) + .route( + "/api/v1/invites/{token}/approve", + post(routes::invites::post_invite_approve), + ) // Key fetch endpoint (API token authentication) .route("/api/v1/keys", get(routes::keys::get_key)) // Agent management endpoints diff --git a/module/iron_control_api/src/routes/invites.rs b/module/iron_control_api/src/routes/invites.rs new file mode 100644 index 00000000..2c7138f6 --- /dev/null +++ b/module/iron_control_api/src/routes/invites.rs @@ -0,0 +1,422 @@ +//! Invite link API endpoints +//! +//! Endpoints: +//! - POST `/api/v1/invites/generate` — create invite link (Admin) +//! - GET `/api/v1/invites/{token}` — preview workspace via invite (Public) +//! - POST `/api/v1/invites/{token}/accept` — accept invite, create account (Public) +//! - POST `/api/v1/invites/{token}/approve`— approve pending member (Admin) + +use core::str::FromStr; +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Json}, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use rand::RngExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::SqlitePool; +use uuid::Uuid; + +use crate::{ + error::{ApiError, ApiResult, JsonBody}, + jwt_auth::{AuthenticatedUser, JwtSecret}, + rbac::{Permission, PermissionChecker, Role}, +}; + +/// BCrypt cost for new member passwords (matches UserService::BCRYPT_COST). +const BCRYPT_COST: u32 = 12; + +/// State for invite endpoints. +#[derive(Clone)] +pub struct InviteState { + /// Database pool. + pub pool: SqlitePool, + /// JWT signing secret for generating access tokens on invite accept. + pub jwt_secret: Arc, +} + +impl core::fmt::Debug for InviteState { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("InviteState") + .field("pool", &"") + .field("jwt_secret", &"") + .finish() + } +} + +fn check_permission(role_str: &str, permission: Permission) -> Result<(), ApiError> { + let role = Role::from_str(role_str) + .map_err(|_| ApiError::Forbidden(format!("Invalid role: {role_str}")))?; + let checker = PermissionChecker::new(); + if checker.has_permission(role, permission) { + Ok(()) + } else { + Err(ApiError::Forbidden(format!( + "Permission {permission:?} required" + ))) + } +} + +/// Generate 32 random bytes encoded as URL-safe base64 (no padding). +fn generate_invite_token() -> String { + let mut rng = rand::rng(); + let mut bytes = [0u8; 32]; + rng.fill(&mut bytes[..]); + URL_SAFE_NO_PAD.encode(bytes) +} + +/// SHA-256 of a token string, hex-encoded. +fn hash_token(token: &str) -> String { + hex::encode(Sha256::digest(token.as_bytes())) +} + +/// Current time in milliseconds since Unix epoch. +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| { + let ms = d.as_millis(); + i64::try_from(ms).unwrap_or(i64::MAX) + }) + .unwrap_or(0) +} + +// Request / Response types ---------------------------------------------------- + +/// Request body for `POST /api/v1/invites/generate`. +#[derive(Debug, Deserialize)] +pub struct GenerateInviteRequest { + /// Number of people who can use this link. + pub seats: i64, + /// Optional lifetime in hours (omit for no expiry). + pub expires_in_hours: Option, +} + +/// Response from `POST /api/v1/invites/generate`. +#[derive(Debug, Serialize)] +pub struct GenerateInviteResponse { + /// Raw invite token to embed in the invite URL. + pub token: String, + /// Suggested join URL path (e.g. `/join/{token}`). + pub url: String, + /// Unix-ms expiry timestamp, or `null` if the link never expires. + pub expires_at: Option, + /// Total seats on this link. + pub seats_total: i64, +} + +/// Response from `GET /api/v1/invites/:token`. +#[derive(Debug, Serialize)] +pub struct InvitePreviewResponse { + /// Workspace display name. + pub workspace_name: String, + /// Primary domain. + pub domain: String, + /// Default AI model for the workspace, if configured. + pub default_model: Option, + /// Remaining unused seats on this link. + pub seats_remaining: i64, +} + +/// Request body for `POST /api/v1/invites/:token/accept`. +#[derive(Debug, Deserialize)] +pub struct AcceptInviteRequest { + /// Password for the new member account. + pub password: String, +} + +/// Response from `POST /api/v1/invites/:token/accept`. +#[derive(Debug, Serialize)] +pub struct AcceptInviteResponse { + /// Newly created user ID. + pub user_id: String, + /// JWT access token (30-day lifetime). + pub access_token: String, +} + +/// Request body for `POST /api/v1/invites/:token/approve`. +#[derive(Debug, Deserialize)] +pub struct ApproveInviteRequest { + /// ID of the pending member to approve. + pub user_id: String, +} + +// Handlers -------------------------------------------------------------------- + +/// POST /api/v1/invites/generate +/// +/// Create an invite link for the workspace. Returns a raw token that must be +/// embedded in the join URL sent to prospective members. +/// +/// # Errors +/// +/// Returns `ApiError` on permission failure, missing workspace, or database error. +pub async fn post_invite_generate( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + JsonBody(body): JsonBody, +) -> ApiResult { + check_permission(&claims.role, Permission::ManageUsers)?; + + if body.seats < 1 { + return Err(ApiError::BadRequest("seats must be at least 1".into())); + } + + let token = generate_invite_token(); + let token_hash = hash_token(&token); + let link_id = Uuid::new_v4().to_string(); + let now = now_ms(); + + let expires_at: Option = body.expires_in_hours.map(|h| now + h * 3_600_000); + + sqlx::query( + r" + INSERT INTO invite_links (id, workspace_id, token_hash, seats_total, seats_used, expires_at, created_by) + VALUES (?, 1, ?, ?, 0, ?, ?) + ", + ) + .bind(&link_id) + .bind(&token_hash) + .bind(body.seats) + .bind(expires_at) + .bind(&claims.sub) + .execute(&state.pool) + .await + .map_err(|_| ApiError::Internal("Failed to create invite link".into()))?; + + let url = format!("/join/{token}"); + + Ok(( + StatusCode::CREATED, + Json(GenerateInviteResponse { + token, + url, + expires_at, + seats_total: body.seats, + }), + )) +} + +/// GET /api/v1/invites/:token +/// +/// Return a workspace preview for an invite link. Public - no authentication required. +/// +/// # Errors +/// +/// Returns `ApiError` if the token is unknown, expired, or the link is full. +pub async fn get_invite_preview( + State(state): State, + Path(token): Path, +) -> ApiResult { + let token_hash = hash_token(&token); + + let row: Option<(String, String, Option, i64, i64, Option)> = sqlx::query_as( + r" + SELECT w.name, w.domain, + wp.default_model, + il.seats_total, il.seats_used, il.expires_at + FROM invite_links il + JOIN workspaces w ON w.id = il.workspace_id + LEFT JOIN workspace_policy wp ON wp.workspace_id = il.workspace_id + WHERE il.token_hash = ? + ", + ) + .bind(&token_hash) + .fetch_optional(&state.pool) + .await + .map_err(|_| ApiError::Internal("Failed to query invite".into()))?; + + let (workspace_name, domain, default_model, seats_total, seats_used, expires_at) = + row.ok_or_else(|| ApiError::NotFound("Invite not found".into()))?; + + if let Some(exp) = expires_at { + if now_ms() > exp { + return Err(ApiError::BadRequest("Invite link has expired".into())); + } + } + + let seats_remaining = seats_total - seats_used; + if seats_remaining <= 0 { + return Err(ApiError::BadRequest("Invite link is full".into())); + } + + Ok(( + StatusCode::OK, + Json(InvitePreviewResponse { + workspace_name, + domain, + default_model, + seats_remaining, + }), + )) +} + +/// POST /api/v1/invites/:token/accept +/// +/// Create a member account and claim a seat on this invite link. +/// Returns a JWT access token the new member can use immediately. +/// Public - no authentication required. +/// +/// # Errors +/// +/// Returns `ApiError` if the token is invalid, expired, full, password is too +/// short, or the database operation fails. +pub async fn post_invite_accept( + State(state): State, + Path(token): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + if body.password.len() < 8 { + return Err(ApiError::BadRequest( + "Password must be at least 8 characters".into(), + )); + } + + let token_hash = hash_token(&token); + + let row: Option<(String, i64, i64, Option)> = sqlx::query_as( + r" + SELECT id, seats_total, seats_used, expires_at + FROM invite_links + WHERE token_hash = ? + ", + ) + .bind(&token_hash) + .fetch_optional(&state.pool) + .await + .map_err(|_| ApiError::Internal("Failed to query invite".into()))?; + + let (link_id, seats_total, seats_used, expires_at) = + row.ok_or_else(|| ApiError::NotFound("Invite not found".into()))?; + + if let Some(exp) = expires_at { + if now_ms() > exp { + return Err(ApiError::BadRequest("Invite link has expired".into())); + } + } + + if seats_used >= seats_total { + return Err(ApiError::BadRequest("Invite link is full".into())); + } + + let user_id = Uuid::new_v4().to_string(); + let username = format!("member_{}", &user_id[..8]); + let user_email = format!("{user_id}@invite.local"); + + let password_hash = bcrypt::hash(&body.password, BCRYPT_COST) + .map_err(|_| ApiError::Internal("Failed to hash password".into()))?; + + sqlx::query( + r" + INSERT INTO users (id, username, password_hash, email, role, is_active, created_at) + VALUES (?, ?, ?, ?, 'developer', 1, ?) + ", + ) + .bind(&user_id) + .bind(&username) + .bind(&password_hash) + .bind(&user_email) + .bind(now_ms()) + .execute(&state.pool) + .await + .map_err(|_| ApiError::Internal("Failed to create member account".into()))?; + let accepted_at = now_ms(); + + sqlx::query( + r" + INSERT INTO invite_seats (invite_link_id, user_id, accepted_at) + VALUES (?, ?, ?) + ", + ) + .bind(&link_id) + .bind(&user_id) + .bind(accepted_at) + .execute(&state.pool) + .await + .map_err(|_| ApiError::Internal("Failed to record invite seat".into()))?; + + sqlx::query( + r" + UPDATE invite_links SET seats_used = seats_used + 1 WHERE id = ? + ", + ) + .bind(&link_id) + .execute(&state.pool) + .await + .map_err(|_| ApiError::Internal("Failed to update seat count".into()))?; + + let token_jti = Uuid::new_v4().to_string(); + let access_token = state + .jwt_secret + .generate_access_token(&user_id, &user_email, "developer", &token_jti) + .map_err(|_| ApiError::Internal("Failed to generate access token".into()))?; + + Ok(( + StatusCode::CREATED, + Json(AcceptInviteResponse { + user_id, + access_token, + }), + )) +} + +/// POST /api/v1/invites/:token/approve +/// +/// Mark a pending invite-seat member as approved. The seat is identified by +/// the invite link token and the member's user ID. +/// +/// # Errors +/// +/// Returns `ApiError` on permission failure, unknown token, unknown user, or +/// database error. +pub async fn post_invite_approve( + State(state): State, + AuthenticatedUser(claims): AuthenticatedUser, + Path(token): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + check_permission(&claims.role, Permission::ManageUsers)?; + + let token_hash = hash_token(&token); + + let link_id: Option = sqlx::query_scalar( + r" + SELECT id FROM invite_links WHERE token_hash = ? + ", + ) + .bind(&token_hash) + .fetch_optional(&state.pool) + .await + .map_err(|_| ApiError::Internal("Failed to query invite link".into()))?; + + let link_id = link_id.ok_or_else(|| ApiError::NotFound("Invite not found".into()))?; + + let approved_at = now_ms(); + let rows_affected = sqlx::query( + r" + UPDATE invite_seats + SET approved_at = ?, approved_by = ? + WHERE invite_link_id = ? AND user_id = ? AND approved_at IS NULL + ", + ) + .bind(approved_at) + .bind(&claims.sub) + .bind(&link_id) + .bind(&body.user_id) + .execute(&state.pool) + .await + .map_err(|_| ApiError::Internal("Failed to approve invite seat".into()))? + .rows_affected(); + + if rows_affected == 0 { + return Err(ApiError::NotFound( + "Pending seat not found for this user".into(), + )); + } + + Ok(StatusCode::OK) +} diff --git a/module/iron_control_api/src/routes/mod.rs b/module/iron_control_api/src/routes/mod.rs index a3855a51..64d82421 100644 --- a/module/iron_control_api/src/routes/mod.rs +++ b/module/iron_control_api/src/routes/mod.rs @@ -10,6 +10,7 @@ pub mod budget; pub mod freeform; pub mod health; pub mod ic_token; +pub mod invites; pub mod keys; pub mod limits; pub mod providers; diff --git a/module/iron_control_api/tests/invites_api_test.rs b/module/iron_control_api/tests/invites_api_test.rs new file mode 100644 index 00000000..a8ddacc2 --- /dev/null +++ b/module/iron_control_api/tests/invites_api_test.rs @@ -0,0 +1,530 @@ +//! Integration tests for invite link endpoints. + +#![allow(missing_docs)] + +mod common; + +use axum::{ + body::Body, + http::{Method, Request, StatusCode}, + routing::{get, post}, + Router, +}; +use common::budget::setup_test_db; +use iron_control_api::{ + jwt_auth::JwtSecret, + routes::{ + auth::AuthState, + invites::{ + get_invite_preview, post_invite_accept, post_invite_approve, post_invite_generate, + InviteState, + }, + }, +}; +use serde_json::{json, Value}; +use tower::ServiceExt; + +const TEST_JWT_SECRET: &str = "test_jwt_secret_invites_api_test_42"; +const ADMIN_USER_ID: &str = "admin_user_001"; + +#[derive(Clone)] +struct TestState { + invite: InviteState, + auth: AuthState, +} + +impl axum::extract::FromRef for InviteState { + fn from_ref(s: &TestState) -> Self { + s.invite.clone() + } +} + +impl axum::extract::FromRef for AuthState { + fn from_ref(s: &TestState) -> Self { + s.auth.clone() + } +} + +async fn setup() -> (TestState, String) { + let pool = setup_test_db().await; + + sqlx::query( + r" + INSERT OR IGNORE INTO workspaces (id, name, domain, account_type) + VALUES (1, 'Test Corp', 'test.example.com', 'client') + ", + ) + .execute(&pool) + .await + .expect("LOUD FAILURE: Failed to seed workspace"); + + let now_ms: i64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + .try_into() + .unwrap_or(i64::MAX); + let password_hash = bcrypt::hash("adminpass123", 4).unwrap(); + sqlx::query( + r" + INSERT OR IGNORE INTO users (id, username, password_hash, email, role, is_active, created_at) + VALUES (?, 'test_admin', ?, 'admin@test.local', 'admin', 1, ?) + ", + ) + .bind(ADMIN_USER_ID) + .bind(&password_hash) + .bind(now_ms) + .execute(&pool) + .await + .expect("LOUD FAILURE: Failed to seed admin user"); + + let jwt = JwtSecret::new(TEST_JWT_SECRET.to_string()); + let token = jwt + .generate_access_token(ADMIN_USER_ID, "admin@test.local", "admin", "tok_admin_001") + .expect("LOUD FAILURE: Failed to generate admin JWT"); + let bearer = format!("Bearer {token}"); + + let auth = AuthState::new(TEST_JWT_SECRET.to_string(), "sqlite::memory:", false) + .await + .expect("LOUD FAILURE: Failed to create AuthState"); + + let invite = InviteState { + pool, + jwt_secret: auth.jwt_secret.clone(), + }; + + (TestState { invite, auth }, bearer) +} + +fn build_router(state: TestState) -> Router { + Router::new() + .route("/api/v1/invites/generate", post(post_invite_generate)) + .route("/api/v1/invites/{token}", get(get_invite_preview)) + .route("/api/v1/invites/{token}/accept", post(post_invite_accept)) + .route("/api/v1/invites/{token}/approve", post(post_invite_approve)) + .with_state(state) +} + +async fn body_json(resp: axum::response::Response) -> Value { + let bytes = axum::body::to_bytes(resp.into_body(), 65_536) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap_or_default() +} + +// Generate -------------------------------------------------------------------- + +#[tokio::test] +async fn test_generate_invite_ok() { + let (state, bearer) = setup().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/invites/generate") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from(json!({"seats": 5}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::CREATED); + let body = body_json(resp).await; + assert!(body["token"].is_string()); + assert!(body["url"].as_str().unwrap().starts_with("/join/")); + assert_eq!(body["seats_total"], 5); + assert!(body["expires_at"].is_null()); +} + +#[tokio::test] +async fn test_generate_with_expiry_ok() { + let (state, bearer) = setup().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/invites/generate") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from( + json!({"seats": 1, "expires_in_hours": 48}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::CREATED); + let body = body_json(resp).await; + assert!(body["expires_at"].is_number()); +} + +#[tokio::test] +async fn test_generate_requires_admin() { + let (state, _) = setup().await; + let app = build_router(state); + + let jwt = JwtSecret::new(TEST_JWT_SECRET.to_string()); + let token = jwt + .generate_access_token("dev_1", "dev@test.local", "developer", "tok_dev") + .unwrap(); + let bearer = format!("Bearer {token}"); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/invites/generate") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from(json!({"seats": 1}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn test_generate_zero_seats_returns_400() { + let (state, bearer) = setup().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/invites/generate") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from(json!({"seats": 0}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +// Preview --------------------------------------------------------------------- + +#[tokio::test] +async fn test_preview_invite_ok() { + let (state, bearer) = setup().await; + let pool = state.invite.pool.clone(); + let app = build_router(state); + + // Generate a link first + let gen_resp = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/invites/generate") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from(json!({"seats": 3}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + let _ = pool; + let gen_body = body_json(gen_resp).await; + let token = gen_body["token"].as_str().unwrap().to_string(); + + let preview_resp = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri(format!("/api/v1/invites/{token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(preview_resp.status(), StatusCode::OK); + let body = body_json(preview_resp).await; + assert_eq!(body["workspace_name"], "Test Corp"); + assert_eq!(body["domain"], "test.example.com"); + assert_eq!(body["seats_remaining"], 3); +} + +#[tokio::test] +async fn test_preview_unknown_token_returns_404() { + let (state, _) = setup().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/api/v1/invites/totally_fake_token_that_doesnt_exist") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +// Accept ---------------------------------------------------------------------- + +#[tokio::test] +async fn test_accept_invite_ok() { + let (state, bearer) = setup().await; + let app = build_router(state); + + let gen_body = body_json( + app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/invites/generate") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from(json!({"seats": 2}).to_string())) + .unwrap(), + ) + .await + .unwrap(), + ) + .await; + + let token = gen_body["token"].as_str().unwrap().to_string(); + + let accept_resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri(format!("/api/v1/invites/{token}/accept")) + .header("Content-Type", "application/json") + .body(Body::from(json!({"password": "secure_pw_123"}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(accept_resp.status(), StatusCode::CREATED); + let body = body_json(accept_resp).await; + assert!(body["user_id"].is_string()); + assert!(body["access_token"].is_string()); +} + +#[tokio::test] +async fn test_accept_short_password_returns_400() { + let (state, bearer) = setup().await; + let app = build_router(state); + + let gen_body = body_json( + app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/invites/generate") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from(json!({"seats": 1}).to_string())) + .unwrap(), + ) + .await + .unwrap(), + ) + .await; + let token = gen_body["token"].as_str().unwrap().to_string(); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri(format!("/api/v1/invites/{token}/accept")) + .header("Content-Type", "application/json") + .body(Body::from(json!({"password": "short"}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_accept_unknown_token_returns_404() { + let (state, _) = setup().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/invites/nonexistent_token/accept") + .header("Content-Type", "application/json") + .body(Body::from( + json!({"password": "secure_password"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +// Approve --------------------------------------------------------------------- + +#[tokio::test] +async fn test_approve_pending_member_ok() { + let (state, bearer) = setup().await; + let app = build_router(state); + + let jwt = JwtSecret::new(TEST_JWT_SECRET.to_string()); + let admin_bearer_2 = format!( + "Bearer {}", + jwt + .generate_access_token(ADMIN_USER_ID, "admin@test.local", "admin", "tok_admin_002") + .unwrap() + ); + + // 1. Generate invite + let gen_body = body_json( + app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/invites/generate") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from(json!({"seats": 5}).to_string())) + .unwrap(), + ) + .await + .unwrap(), + ) + .await; + let token = gen_body["token"].as_str().unwrap().to_string(); + + // 2. Accept invite + let accept_body = body_json( + app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(format!("/api/v1/invites/{token}/accept")) + .header("Content-Type", "application/json") + .body(Body::from(json!({"password": "member_pw_123"}).to_string())) + .unwrap(), + ) + .await + .unwrap(), + ) + .await; + let new_user_id = accept_body["user_id"].as_str().unwrap().to_string(); + + // 3. Approve pending member + let approve_resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri(format!("/api/v1/invites/{token}/approve")) + .header("Content-Type", "application/json") + .header("Authorization", admin_bearer_2) + .body(Body::from(json!({"user_id": new_user_id}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(approve_resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_approve_requires_admin() { + let (state, _) = setup().await; + let app = build_router(state); + + let jwt = JwtSecret::new(TEST_JWT_SECRET.to_string()); + let dev_bearer = format!( + "Bearer {}", + jwt + .generate_access_token("dev_user", "dev@test.local", "developer", "tok_dev_2") + .unwrap() + ); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/invites/some_token/approve") + .header("Content-Type", "application/json") + .header("Authorization", dev_bearer) + .body(Body::from(json!({"user_id": "some_user"}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn test_approve_unknown_user_returns_404() { + let (state, bearer) = setup().await; + let app = build_router(state); + + let jwt = JwtSecret::new(TEST_JWT_SECRET.to_string()); + let admin_bearer_2 = format!( + "Bearer {}", + jwt + .generate_access_token(ADMIN_USER_ID, "admin@test.local", "admin", "tok_admin_003") + .unwrap() + ); + + // Generate invite + let gen_body = body_json( + app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/invites/generate") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from(json!({"seats": 1}).to_string())) + .unwrap(), + ) + .await + .unwrap(), + ) + .await; + let token = gen_body["token"].as_str().unwrap().to_string(); + + // Try to approve non-existent user + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri(format!("/api/v1/invites/{token}/approve")) + .header("Content-Type", "application/json") + .header("Authorization", admin_bearer_2) + .body(Body::from( + json!({"user_id": "nonexistent_user_id"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} From 3338f9fed46b65b2e36f9cdef16bedf702205efc Mon Sep 17 00:00:00 2001 From: Ivan Kinder Date: Thu, 14 May 2026 17:03:59 +0300 Subject: [PATCH 09/46] test(api): add HTTP-level integration tests for freeform and workspace handlers --- module/iron_control_api/src/routes/invites.rs | 28 +- .../tests/freeform_api_test.rs | 382 ++++++++++++++++++ 2 files changed, 401 insertions(+), 9 deletions(-) create mode 100644 module/iron_control_api/tests/freeform_api_test.rs diff --git a/module/iron_control_api/src/routes/invites.rs b/module/iron_control_api/src/routes/invites.rs index 2c7138f6..51335432 100644 --- a/module/iron_control_api/src/routes/invites.rs +++ b/module/iron_control_api/src/routes/invites.rs @@ -27,7 +27,7 @@ use crate::{ rbac::{Permission, PermissionChecker, Role}, }; -/// BCrypt cost for new member passwords (matches UserService::BCRYPT_COST). +/// `BCrypt` cost for new member passwords (matches `UserService::BCRYPT_COST`). const BCRYPT_COST: u32 = 12; /// State for invite endpoints. @@ -201,6 +201,17 @@ pub async fn post_invite_generate( )) } +/// DB row returned by the invite-preview query. +#[derive(sqlx::FromRow)] +struct InviteLinkRow { + name: String, + domain: String, + default_model: Option, + seats_total: i64, + seats_used: i64, + expires_at: Option, +} + /// GET /api/v1/invites/:token /// /// Return a workspace preview for an invite link. Public - no authentication required. @@ -214,7 +225,7 @@ pub async fn get_invite_preview( ) -> ApiResult { let token_hash = hash_token(&token); - let row: Option<(String, String, Option, i64, i64, Option)> = sqlx::query_as( + let row: Option = sqlx::query_as( r" SELECT w.name, w.domain, wp.default_model, @@ -230,16 +241,15 @@ pub async fn get_invite_preview( .await .map_err(|_| ApiError::Internal("Failed to query invite".into()))?; - let (workspace_name, domain, default_model, seats_total, seats_used, expires_at) = - row.ok_or_else(|| ApiError::NotFound("Invite not found".into()))?; + let row = row.ok_or_else(|| ApiError::NotFound("Invite not found".into()))?; - if let Some(exp) = expires_at { + if let Some(exp) = row.expires_at { if now_ms() > exp { return Err(ApiError::BadRequest("Invite link has expired".into())); } } - let seats_remaining = seats_total - seats_used; + let seats_remaining = row.seats_total - row.seats_used; if seats_remaining <= 0 { return Err(ApiError::BadRequest("Invite link is full".into())); } @@ -247,9 +257,9 @@ pub async fn get_invite_preview( Ok(( StatusCode::OK, Json(InvitePreviewResponse { - workspace_name, - domain, - default_model, + workspace_name: row.name, + domain: row.domain, + default_model: row.default_model, seats_remaining, }), )) diff --git a/module/iron_control_api/tests/freeform_api_test.rs b/module/iron_control_api/tests/freeform_api_test.rs new file mode 100644 index 00000000..16a501c2 --- /dev/null +++ b/module/iron_control_api/tests/freeform_api_test.rs @@ -0,0 +1,382 @@ +//! Integration tests for POST /freeform/company and POST /freeform/providers handlers. + +#![allow(missing_docs)] + +mod common; + +use std::sync::Arc; + +use axum::{ + body::Body, + http::{Method, Request, StatusCode}, + routing::post, + Router, +}; +use common::budget::setup_test_db; +use iron_control_api::{ + jwt_auth::JwtSecret, + routes::{ + auth::AuthState, + freeform::{post_company, post_providers, FreeformState}, + }, +}; +use iron_secrets::crypto::CryptoService; +use iron_token_manager::provider_key_storage::ProviderKeyStorage; +use serde_json::{json, Value}; +use tower::ServiceExt; + +const TEST_JWT_SECRET: &str = "test_jwt_secret_freeform_api_test_77"; +const MASTER_KEY: [u8; 32] = [7u8; 32]; + +#[derive(Clone)] +struct TestState { + freeform: FreeformState, + auth: AuthState, +} + +impl axum::extract::FromRef for FreeformState { + fn from_ref(s: &TestState) -> Self { + s.freeform.clone() + } +} + +impl axum::extract::FromRef for AuthState { + fn from_ref(s: &TestState) -> Self { + s.auth.clone() + } +} + +async fn setup_with_crypto() -> (TestState, String) { + let pool = setup_test_db().await; + + let crypto = Arc::new(CryptoService::new(&MASTER_KEY).unwrap()); + let storage = Arc::new(ProviderKeyStorage::new(pool.clone())); + let auth = AuthState::new(TEST_JWT_SECRET.to_string(), "sqlite::memory:", false) + .await + .unwrap(); + + let freeform = FreeformState { + pool, + provider_storage: storage, + crypto: Some(crypto), + }; + + let jwt = JwtSecret::new(TEST_JWT_SECRET.to_string()); + let token = jwt + .generate_access_token("admin_1", "admin@test.local", "admin", "tok_001") + .unwrap(); + + (TestState { freeform, auth }, format!("Bearer {token}")) +} + +async fn setup_no_crypto() -> (TestState, String) { + let pool = setup_test_db().await; + let storage = Arc::new(ProviderKeyStorage::new(pool.clone())); + let auth = AuthState::new(TEST_JWT_SECRET.to_string(), "sqlite::memory:", false) + .await + .unwrap(); + + let freeform = FreeformState { + pool, + provider_storage: storage, + crypto: None, + }; + + let jwt = JwtSecret::new(TEST_JWT_SECRET.to_string()); + let token = jwt + .generate_access_token("admin_1", "admin@test.local", "admin", "tok_002") + .unwrap(); + + (TestState { freeform, auth }, format!("Bearer {token}")) +} + +fn dev_bearer() -> String { + let jwt = JwtSecret::new(TEST_JWT_SECRET.to_string()); + let token = jwt + .generate_access_token("dev_1", "dev@test.local", "developer", "tok_dev") + .unwrap(); + format!("Bearer {token}") +} + +fn build_router(state: TestState) -> Router { + Router::new() + .route("/api/v1/freeform/company", post(post_company)) + .route("/api/v1/freeform/providers", post(post_providers)) + .with_state(state) +} + +async fn body_json(resp: axum::response::Response) -> Value { + let bytes = axum::body::to_bytes(resp.into_body(), 65_536) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap_or_default() +} + +// POST /freeform/company ------------------------------------------------------ + +#[tokio::test] +async fn test_company_ok() { + let (state, bearer) = setup_with_crypto().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/freeform/company") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from( + json!({"text": "Acme Corp, acme.example.com, Client"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["name"], "Acme Corp"); + assert_eq!(body["domain"], "acme.example.com"); + assert_eq!(body["account_type"], "client"); + assert_eq!(body["id"], 1); +} + +#[tokio::test] +async fn test_company_upsert_updates_fields() { + let (state, bearer) = setup_with_crypto().await; + let app = build_router(state); + + let jwt = JwtSecret::new(TEST_JWT_SECRET.to_string()); + let bearer2 = format!( + "Bearer {}", + jwt + .generate_access_token("admin_1", "admin@test.local", "admin", "tok_003") + .unwrap() + ); + + let _ = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/freeform/company") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from( + json!({"text": "Old Corp, old.com, Client"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/freeform/company") + .header("Content-Type", "application/json") + .header("Authorization", bearer2) + .body(Body::from( + json!({"text": "New Corp, new.com, Internal"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["name"], "New Corp"); + assert_eq!(body["account_type"], "internal"); +} + +#[tokio::test] +async fn test_company_requires_admin() { + let (state, _) = setup_with_crypto().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/freeform/company") + .header("Content-Type", "application/json") + .header("Authorization", dev_bearer()) + .body(Body::from( + json!({"text": "Acme, acme.com, Client"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn test_company_bad_format_returns_400() { + let (state, bearer) = setup_with_crypto().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/freeform/company") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from(json!({"text": "just one field"}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_company_unknown_account_type_returns_400() { + let (state, bearer) = setup_with_crypto().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/freeform/company") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from( + json!({"text": "Acme, acme.com, Partner"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +// POST /freeform/providers ---------------------------------------------------- + +#[tokio::test] +async fn test_providers_ok() { + let (state, bearer) = setup_with_crypto().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/freeform/providers") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from( + json!({"text": "openai: sk-testkey000\nanthropic: sk-ant-testkey000"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + let applied = body["applied"].as_array().unwrap(); + assert_eq!(applied.len(), 2); + assert!(applied.iter().any(|e| e["provider"] == "openai")); + assert!(applied.iter().any(|e| e["provider"] == "anthropic")); +} + +#[tokio::test] +async fn test_providers_requires_admin() { + let (state, _) = setup_with_crypto().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/freeform/providers") + .header("Content-Type", "application/json") + .header("Authorization", dev_bearer()) + .body(Body::from( + json!({"text": "openai: sk-testkey000"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn test_providers_no_crypto_returns_503() { + let (state, bearer) = setup_no_crypto().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/freeform/providers") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from( + json!({"text": "openai: sk-testkey000"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); +} + +#[tokio::test] +async fn test_providers_bad_format_returns_400() { + let (state, bearer) = setup_with_crypto().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/freeform/providers") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from( + json!({"text": "this is not a provider line"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_providers_unsupported_provider_returns_400() { + let (state, bearer) = setup_with_crypto().await; + let app = build_router(state); + + let resp = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/v1/freeform/providers") + .header("Content-Type", "application/json") + .header("Authorization", bearer) + .body(Body::from( + json!({"text": "mistral: sk-testkey000"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} From 6e82241fd427d2522c43f0be0c6082df6ffd5bab Mon Sep 17 00:00:00 2001 From: Ivan Kinder Date: Mon, 18 May 2026 10:38:38 +0300 Subject: [PATCH 10/46] feat(dashboard): add shared FreeFormDialog and FreeFormToggle components --- .../components/freeform/FreeFormDialog.vue | 113 ++++++++++++++++++ .../components/freeform/FreeFormToggle.vue | 23 ++++ .../src/components/freeform/index.ts | 2 + 3 files changed, 138 insertions(+) create mode 100644 module/iron_dashboard/src/components/freeform/FreeFormDialog.vue create mode 100644 module/iron_dashboard/src/components/freeform/FreeFormToggle.vue create mode 100644 module/iron_dashboard/src/components/freeform/index.ts diff --git a/module/iron_dashboard/src/components/freeform/FreeFormDialog.vue b/module/iron_dashboard/src/components/freeform/FreeFormDialog.vue new file mode 100644 index 00000000..b39f93b5 --- /dev/null +++ b/module/iron_dashboard/src/components/freeform/FreeFormDialog.vue @@ -0,0 +1,113 @@ + + +