Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
b6e5f2e
chore(config): add nextest profile to suppress passing test output
alchezar May 13, 2026
ff97366
feat(freeform): add deterministic parsers for providers/invites and u…
alchezar May 13, 2026
953dc02
refactor(freeform): split providers-and-invites into separate parsers…
alchezar May 14, 2026
ec0a6d2
feat(freeform): add CSV-style parser for company setup
alchezar May 14, 2026
f6e2537
feat(db): add migrations for workspace_policy, invite_links and invit…
alchezar May 14, 2026
a77c6bc
feat(api): add POST /freeform/company and POST /freeform/providers en…
alchezar May 14, 2026
0ac66c5
feat(api): add POST /workspace/budget endpoint
alchezar May 14, 2026
b32f5a3
feat(api): add invite endpoints
alchezar May 14, 2026
3338f9f
test(api): add HTTP-level integration tests for freeform and workspac…
alchezar May 14, 2026
6e82241
feat(dashboard): add shared FreeFormDialog and FreeFormToggle components
alchezar May 18, 2026
2594da8
feat(dashboard): add auto_setup wizard
alchezar May 18, 2026
5a9a675
feat(dashboard): add company setup FreeForm screen
alchezar May 18, 2026
17f1c43
feat(dashboard): add providers FreeForm screen
alchezar May 18, 2026
0a31fcd
feat(dashboard): add budget form screen
alchezar May 18, 2026
63c2e1a
feat(dashboard): add users screen with invite link generation
alchezar May 18, 2026
7a364f0
feat(dashboard): add invite accept screen
alchezar May 18, 2026
d13c17b
chore(dashboard): migrate ESLint config to flat config
alchezar May 18, 2026
b233393
feat(api): add pending invites listing and seat-based approve
alchezar May 18, 2026
029070f
feat(dashboard): add admin pending invites approval UI
alchezar May 18, 2026
5d6c068
feat(api): add GET /me/workspace endpoint
alchezar May 18, 2026
c8d4e2e
feat(dashboard): add member dashboard with IC token and gateway URL
alchezar May 18, 2026
7aa0e28
chore(dashboard): fix vue/attributes-order warnings in legacy views
alchezar May 18, 2026
93fb97f
feat(db): seed default workspace row in migration 034
alchezar May 18, 2026
c819f15
feat(dashboard): mount AutoSetupWizard on admin dashboard
alchezar May 18, 2026
489b27e
feat(dashboard): add setup-routes navigation in MainLayout
alchezar May 18, 2026
b6dd2a5
feat(dashboard): handle missing workspace on member dashboard with em…
alchezar May 18, 2026
21416f6
fix(api): persist default_model in workspace budget upsert
alchezar May 18, 2026
c7b8276
docs(freeform): add readme.md with Responsibility Table
alchezar May 25, 2026
3b01782
docs(routes): register freeform/invites/workspace in routes readme
alchezar May 25, 2026
77cc294
test(invites): add regression test for TOCTOU seat-limit race on accept
alchezar May 25, 2026
db31032
fix(invites): wrap accept seat-claim in atomic transaction
alchezar May 25, 2026
7bca3ec
test(workspace): add regression test for Manager budget RBAC guard
alchezar May 25, 2026
5ce7e4c
fix(workspace): use ManageIcTokens permission for budget endpoint
alchezar May 25, 2026
fec12a7
fix(tests): avoid migration 034 workspace seed collision in invite tests
alchezar May 25, 2026
06d449f
feat(invites): rate-limit public bcrypt-heavy accept endpoint
alchezar May 25, 2026
743f1ff
fix(dashboard): guard setup routes behind admin check
alchezar May 25, 2026
93269b5
feat(freeform): queue invite emails from providers paste
alchezar May 25, 2026
efed3a8
feat(freeform): implement requestable models policy directive
alchezar May 25, 2026
8551938
refactor(routes): extract shared check_permission helper
alchezar May 25, 2026
0c704f9
chore(nextest): document intentional slow status-level
alchezar May 25, 2026
038cc03
test(freeform): add test_kind markers to new test files
alchezar May 25, 2026
91b0c6b
docs(tests): register new test files in tests readme
alchezar May 25, 2026
4a081e0
docs(dashboard): add readme.md to components/freeform
alchezar May 25, 2026
32b51f1
test(database): sync schema validation tests with freeform migrations…
alchezar May 29, 2026
a751d9a
feat(migrations): add magic_link_tokens table and user registration c…
alchezar May 29, 2026
5e11fe0
feat(auth): add magic-link send and verify endpoints
alchezar May 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# https://nexte.st/book/configuration

[profile.default]
slow-timeout = { period = "30s", terminate-after = 2 }
test-threads = "num-cpus"
# Intentional: "slow" prints only slow + failing/retried tests live, keeping the
# run output readable. Passing tests are deliberately not listed (there are
# hundreds). Do not raise to "pass"/"all". The end-of-run summary still surfaces
# failures via final-status-level below.
status-level = "slow"
final-status-level = "fail"
116 changes: 108 additions & 8 deletions module/iron_control_api/src/bin/iron_control_api_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ use std::{env, fs, path::Path, sync::Arc};
use axum::{
extract::FromRef,
http::{header, HeaderValue, Method},
middleware::from_fn_with_state,
routing::{delete, get, patch, post, put},
Router,
};
Expand All @@ -68,11 +69,12 @@ use zeroize::Zeroizing;

use iron_control_api::{
ic_token::{IcTokenManager, IcTokenRateLimiter},
rate_limiter::LoginRateLimiter,
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, invites::InviteState, keys::KeysState, limits::LimitsState,
providers::ProvidersState, tokens::TokenState, usage::UsageState, users::UserManagementState,
},
token_auth::ApiTokenState,
};
Expand Down Expand Up @@ -216,6 +218,8 @@ struct AppState {
providers: ProvidersState,
keys: KeysState,
users: UserManagementState,
freeform: FreeformState,
invites: InviteState,
agents: SqlitePool,
budget: BudgetState,
analytics: AnalyticsState,
Expand Down Expand Up @@ -278,6 +282,20 @@ impl FromRef<AppState> for UserManagementState {
}
}

/// Enable freeform routes to access `FreeformState` from combined `AppState`
impl FromRef<AppState> for FreeformState {
fn from_ref(state: &AppState) -> Self {
state.freeform.clone()
}
}

/// Enable invite routes to access `InviteState` from combined `AppState`
impl FromRef<AppState> for InviteState {
fn from_ref(state: &AppState) -> Self {
state.invites.clone()
}
}

/// Enable agent routes to access `SqlitePool` from combined `AppState`
impl FromRef<AppState> for SqlitePool {
fn from_ref(state: &AppState) -> Self {
Expand Down Expand Up @@ -447,7 +465,11 @@ async fn main() -> Result<(), Box<dyn Error>> {
// 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"
);
Expand Down Expand Up @@ -550,6 +572,8 @@ async fn main() -> Result<(), Box<dyn Error>> {

// 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(),
Expand Down Expand Up @@ -591,8 +615,21 @@ async fn main() -> Result<(), Box<dyn Error>> {
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),
};

// 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")
let user_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users")
.fetch_one(&agents_pool)
.await
.unwrap_or(0);
Expand Down Expand Up @@ -632,6 +669,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
providers: providers_state,
keys: keys_state,
users: user_management_state,
freeform: freeform_state,
invites: invite_state,
agents: agents_pool,
budget: budget_state,
analytics: analytics_state,
Expand All @@ -644,21 +683,26 @@ async fn main() -> Result<(), Box<dyn Error>> {
let allowed_origins_str = env::var("ALLOWED_ORIGINS")
.expect("ALLOWED_ORIGINS environment variable required (comma-separated URLs)");

let allowed_origins: Vec<HeaderValue> = allowed_origins_str
let allowed_origins = allowed_origins_str
.split(',')
.map(|origin| {
origin
.trim()
.parse::<HeaderValue>()
.unwrap_or_else(|_| panic!("Invalid origin in ALLOWED_ORIGINS: {origin}"))
})
.collect();
.collect::<Vec<_>>();

tracing::info!("✅ Configured CORS for {} origins", allowed_origins.len());
for origin in &allowed_origins {
tracing::info!(" - {}", origin.to_str()?);
}

// Per-IP rate limiter for the public, bcrypt-heavy invite-accept endpoint.
// Reuses the login sliding-window limiter (5 attempts / 5 min per IP) to cap
// CPU spent on bcrypt before the handler runs.
let invite_accept_limiter = LoginRateLimiter::new();

// Build router with all endpoints
let app = Router::new()
// Health check (FR-2: Health endpoint at /api/health)
Expand All @@ -670,6 +714,14 @@ async fn main() -> Result<(), Box<dyn Error>> {
.route("/api/v1/auth/refresh", post(routes::auth::refresh))
.route("/api/v1/auth/logout", post(routes::auth::logout))
.route("/api/v1/auth/validate", post(routes::auth::validate))
.route(
"/api/v1/auth/magic-link/send",
post(routes::auth::magic_link_send),
)
.route(
"/api/v1/auth/magic-link/verify",
post(routes::auth::magic_link_verify),
)
// User management endpoints
.route("/api/v1/users", post(routes::users::create_user))
.route("/api/v1/users", get(routes::users::list_users))
Expand Down Expand Up @@ -756,6 +808,54 @@ async fn main() -> Result<(), Box<dyn Error>> {
"/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),
)
// Workspace management endpoints (Admin)
.route(
"/api/v1/workspace/budget",
post(routes::workspace::post_workspace_budget),
)
.route(
"/api/v1/me/workspace",
get(routes::workspace::get_me_workspace),
)
// Invite link endpoints
.route(
"/api/v1/invites/generate",
post(routes::invites::post_invite_generate),
)
.route(
"/api/v1/invites/pending",
get(routes::invites::get_pending_invites),
)
.route(
"/api/v1/invites/seats/{seat_id}/approve",
post(routes::invites::post_invite_seat_approve),
)
.route(
"/api/v1/invites/{token}",
get(routes::invites::get_invite_preview),
)
// Per-IP rate limit applies to this route only (layer on the MethodRouter),
// rejecting bursts with 429 before bcrypt runs in the handler.
.route(
"/api/v1/invites/{token}/accept",
post(routes::invites::post_invite_accept).layer(from_fn_with_state(
invite_accept_limiter,
routes::invites::invite_accept_rate_limit,
)),
)
.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
Expand Down Expand Up @@ -888,7 +988,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let server_port_str = env::var("SERVER_PORT")
.expect("SERVER_PORT environment variable required (port number 1-65535)");

let server_port: u16 = server_port_str
let server_port = server_port_str
.parse::<u16>()
.unwrap_or_else(|_| panic!("Invalid SERVER_PORT: {server_port_str} (must be 1-65535)"));

Expand Down
2 changes: 1 addition & 1 deletion module/iron_control_api/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ where
req: axum::http::Request<axum::body::Body>,
state: &S,
) -> Result<Self, Self::Rejection> {
match axum::Json::<T>::from_request(req, state).await {
match Json::<T>::from_request(req, state).await {
Ok(value) => Ok(Self(value.0)),
Err(rejection) => {
// Convert Axum's JSON rejection (422) to 400 with JSON error format
Expand Down
110 changes: 110 additions & 0 deletions module/iron_control_api/src/freeform/company_setup.rs
Original file line number Diff line number Diff line change
@@ -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<ParsedCompany, ParseError> {
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,
})
}
Loading
Loading