AionCore is the backend server for AionUi, built with Rust (Axum + Tokio + SQLite). It provides HTTP REST APIs and WebSocket real-time events for the AionUi desktop client.
| Component | Technology |
|---|---|
| Web framework | Axum 0.8 |
| Async runtime | Tokio |
| Database | SQLite (via sqlx, async) |
| Authentication | JWT + CSRF (Double Submit Cookie) |
| Real-time | WebSocket + event broadcasting |
┌─────────────────────────────────────────────────┐
│ aionui-app │
│ (binary entry, router assembly) │
├──────────┬──────────┬──────────┬────────────────┤
│conversa- │ channel │ team │ ... (domain) │
│ tion │ │ │ │
├──────────┴──────────┴──────────┴────────────────┤
│ aionui-auth aionui-realtime │
│ (JWT, CSRF, middleware) (WebSocket, events) │
├─────────────────────────────────────────────────┤
│ aionui-db aionui-api-types aionui-runtime │
│ (repositories) (API contracts) (subprocess/bun) │
├─────────────────────────────────────────────────┤
│ aionui-common aionui-assets │
│ (error types, enums, crypto) (embedded data) │
└─────────────────────────────────────────────────┘
Dependencies flow strictly downward. Domain crates must not depend on aionui-app, and aionui-common has zero internal dependencies.
The project is organized as a Cargo workspace with 20 crates across four layers:
Depended on by nearly all other crates. Changes require careful impact assessment.
| Crate | Responsibility |
|---|---|
aionui-common |
Shared error types (ApiError), enums, ID generation, crypto utilities, timestamps, pagination |
aionui-api-types |
All HTTP/WebSocket request and response types — the single source of truth for API contracts |
aionui-db |
SQLite database layer, defines Repository traits and implementations |
aionui-assets |
Embedded static assets (agent metadata, prompts) |
aionui-runtime |
Subprocess spawning, bun runtime resolution, PATH enhancement |
Cross-cutting capabilities used by domain crates.
| Crate | Responsibility |
|---|---|
aionui-auth |
JWT authentication, password hashing, CSRF protection, cookie management, auth middleware |
aionui-realtime |
WebSocket connection management, event broadcasting (BroadcastEventBus), message routing |
Each crate owns an independent business domain. They remain loosely coupled from each other.
| Crate | Responsibility |
|---|---|
aionui-conversation |
Conversation management, messaging, confirmations, streaming responses |
aionui-channel |
Multi-channel integration (WeChat, DingTalk, Lark), plugin system, pairing sessions |
aionui-team |
Team collaboration, task scheduling, mailbox system |
aionui-cron |
Scheduled job execution, cron expressions, event triggering |
aionui-file |
File operations, watching, snapshots, git operations, compression |
aionui-office |
Office document handling (Excel, PPT, Word), preview, conversion |
aionui-system |
System settings, provider management, version checking, model fetching |
aionui-mcp |
MCP protocol integration, OAuth, multi-platform adapters |
aionui-ai-agent |
Agent lifecycle management, worker task queues, ACP/auxiliary skills |
aionui-extension |
Extension registry, hub management, skill discovery and installation |
aionui-shell |
Shell command execution, speech-to-text |
aionui-assistant |
Assistant configuration and management |
| Crate | Responsibility |
|---|---|
aionui-app |
Top-level binary entry point, assembles all crates into the Axum server |
Composition → Domain → Capability → Foundation
Domain → Foundation (cross-layer allowed)
- ✅ Upper layers may depend on lower layers
- ✅ Same-layer interaction through trait abstractions (e.g., conversation uses ai-agent capability via IWorkerTaskManager trait)
- ❌ Lower layers must not depend on upper layers
- ❌ Circular dependencies are forbidden
Every domain crate follows a consistent internal organization. Using aionui-conversation as a reference:
crates/aionui-conversation/src/
├── lib.rs # Module exports, defines the crate's public API
├── routes.rs # HTTP route handlers
├── service.rs # Business logic layer
├── state.rs # RouterState struct (holds services and dependencies)
├── error.rs # Domain-specific error types (optional)
├── types.rs # Domain models (optional)
└── [modules] # Feature-specific submodules (e.g., streaming.rs)
lib.rs — Crate entry point, only module declarations and public API exports:
- Exports the
domain_routes()function - Exports
ServiceandRouterState - Contains no business logic
routes.rs — HTTP route definitions and handler functions:
- Exports a single
domain_routes(state: RouterState) -> Routerfunction - Each handler: extract parameters → call service → construct response
- Handlers contain no business logic, only request/response transformation
service.rs — The sole location for business logic:
- Dependencies injected via constructor (Repository trait objects, EventBroadcaster, etc.)
- All business rules, validation, and orchestration logic lives here
- Does not import axum or touch HTTP types directly
state.rs — Router state, the carrier for dependency injection:
- Holds service instances and Arc references to other dependencies
- Implements Clone (required by Axum)
async fn handler(
State(state): State<RouterState>, // Dependency injection
Extension(user): Extension<CurrentUser>, // Authenticated user
Path(id): Path<String>, // Path parameter
Json(body): Json<RequestType>, // Request body
) -> Result<(StatusCode, Json<ApiResponse<ResponseType>>), ApiError>Create a new crate when:
- It represents an independent business domain (with its own data models and lifecycle)
- It needs an independent route prefix (e.g.,
/api/new-domain/...) - It has no strong coupling with existing domains
Extend an existing crate when:
- The feature is a sub-feature of an existing domain
- It shares the same data models
- Routes are sub-paths of an existing prefix
/api/{resources} # Collection operations (GET list, POST create)
/api/{resources}/{id} # Item operations (GET detail, PATCH update, DELETE)
/api/{resources}/{id}/{subresources} # Nested resources
/api/{resources}/{id}/{action} # Action operations (only when CRUD cannot express it)
Rules:
- Always use the
/api/prefix - Resource names and path segments use kebab-case (e.g.,
ai-agents,qr-login) - Action routes use verbs or verb phrases (e.g.,
reset,stop,run)
Success response (ApiResponse<T>):
{
"success": true,
"data": { ... },
"message": "optional message"
}Both data and message are optional fields, omitted from serialization when null.
Error response (ErrorResponse):
{
"success": false,
"error": "Human-readable error message",
"code": "ERROR_CODE"
}All response types are defined in aionui-api-types — the single source of truth for API contracts.
| ApiError Variant | Status Code | Error Code | Use Case |
|---|---|---|---|
| BadRequest | 400 | BAD_REQUEST | Invalid request parameters |
| Unauthorized | 401 | UNAUTHORIZED | Not authenticated or token expired |
| Forbidden | 403 | FORBIDDEN | No permission to access |
| NotFound | 404 | NOT_FOUND | Resource does not exist |
| Conflict | 409 | CONFLICT | Resource conflict |
| UnprocessableEntity | 422 | UNPROCESSABLE_ENTITY | Semantic error |
| RateLimited | 429 | RATE_LIMITED | Request rate exceeded |
| Internal | 500 | INTERNAL_ERROR | Internal server error |
| BadGateway | 502 | BAD_GATEWAY | Upstream service failure |
| Timeout | 502 | TIMEOUT | Upstream service timeout |
Uses offset-based pagination (PaginatedResult<T>):
{
"items": [...],
"total": 100,
"hasMore": true
}Field descriptions:
items— Current page datatotal— Total record counthasMore— Whether more data is available
Note: JSON field names use camelCase (via #[serde(rename_all = "camelCase")]).
Entry point: Single /ws endpoint
Message format (WebSocketMessage<T>):
{
"name": "domain.actionName",
"data": { ... }
}Event naming convention:
- Format:
{domain}.{actionName}, two-level structure - domain uses camelCase (e.g.,
conversation,fileWatch) - actionName uses camelCase (e.g.,
listChanged,statusChanged) - Examples:
conversation.listChanged,cron.jobExecuted,extensions.stateChanged
channel.pairing-requested)
or three-level naming (e.g., team.agent.status). These are historical artifacts.
New events must follow the two-level camelCase convention above.
Existing inconsistencies will be unified incrementally during related module iterations.
All database access goes through trait abstractions defined in aionui-db:
#[async_trait]
pub trait IConversationRepository: Send + Sync {
async fn get(&self, id: &str) -> Result<Option<ConversationRow>, DbError>;
async fn create(&self, row: &ConversationRow) -> Result<(), DbError>;
async fn update(&self, id: &str, params: &UpdateConversationParams) -> Result<(), DbError>;
async fn delete(&self, id: &str) -> Result<(), DbError>;
// ...
}Rules:
- Each domain entity has a corresponding Repository trait (e.g.,
IConversationRepository,IUserRepository) - Trait names are prefixed with
Ito denote an interface - Concrete implementations use the
Sqliteprefix (e.g.,SqliteConversationRepository) - Service layer depends only on traits, never on concrete implementations
The project has three categories of data types, each with its own home:
| Type | Location | Purpose | Example |
|---|---|---|---|
| Row models | aionui-db/src/models/ |
Database row mapping | ConversationRow |
| Params objects | aionui-db/src/repository/ |
Database write parameters | UpdateConversationParams |
| Request/response types | aionui-api-types |
API contracts and shared DTOs | CreateConversationRequest, ConversationResponse |
The service layer may directly use types from aionui-api-types. This crate contains
pure data structure definitions with no HTTP framework dependencies, essentially serving as a shared DTO layer.
aionui-api-types must not depend on axum, tower, or any HTTP framework.
Only serde and basic type dependencies are allowed. This is the prerequisite for services to safely use it.
- Handler (routes.rs): Request validation, parameter extraction, error mapping, constructing
ApiResponse - Service (service.rs): Business logic, rule validation, orchestrating Repository calls, Row ↔ Response conversion
- Repository (aionui-db): Pure database operations, no business logic
The boundary between Handler and Service is defined by responsibility, not by types — Handlers do not make business decisions, Services do not handle HTTP concerns.
Using sqlx's embedded migrations (sqlx::migrate!()):
- Migration files are located in
crates/aionui-db/migrations/ - Naming format:
NNN_descriptive_name.sql(sequential numbering) - Migrations run automatically on application startup
- New tables or schema changes must go through migration files — manual database modifications are forbidden
- Use
IF NOT EXISTSto ensure idempotency
DbError (database layer)
↓ From trait implementation (aionui-db/src/error.rs)
ApiError (unified error type)
↓ IntoResponse implementation
HTTP response (status code + ErrorResponse JSON)
Mapping rules:
DbError::NotFound→ApiError::NotFound(preserves semantics)DbError::Conflict→ApiError::Conflict(preserves semantics)DbError::Query/Migration/Init→ApiError::Internal(hides internal details)
The application uses Axum's with_state() pattern for dependency injection in three steps:
Step 1: Centralized service construction (AppServices)
aionui-app defines AppServices, which holds all shared dependencies centrally:
pub struct AppServices {
pub database: Database,
pub jwt_service: Arc<JwtService>,
pub user_repo: Arc<dyn IUserRepository>,
pub cookie_config: Arc<CookieConfig>,
pub qr_token_store: Arc<QrTokenStore>,
pub ws_manager: Arc<WebSocketManager>,
pub event_bus: Arc<BroadcastEventBus>,
pub worker_task_manager: Arc<dyn IWorkerTaskManager>,
pub agent_registry: Arc<AgentRegistry>,
pub conversation_repo: Arc<dyn IConversationRepository>,
pub acp_session_sync: Arc<AcpSessionSyncService>,
pub jwt_secret_raw: String,
pub data_dir: String,
pub local: bool,
pub app_version: String,
pub skill_paths: Arc<SkillPaths>,
pub guide_mcp_config: Option<GuideMcpConfig>,
// ...
}Step 2: Build RouterState per domain
build_module_states() constructs all domain RouterStates from AppServices.
Each domain receives only the dependencies it needs:
// Simple domain — only needs one service
pub struct CronRouterState {
pub cron_service: Arc<CronService>,
}
// Complex domain — needs multiple services
pub struct OfficeRouterState {
pub watch_manager: Arc<OfficecliWatchManager>,
pub snapshot_service: Arc<SnapshotService>,
pub conversion_service: Arc<ConversionService>,
pub proxy_service: Arc<ProxyService>,
}All RouterStates are #[derive(Clone)] and hold Arc-wrapped dependencies.
Step 3: Handlers extract dependencies via State
async fn create(
State(state): State<ConversationRouterState>,
Extension(user): Extension<CurrentUser>,
body: Result<Json<CreateConversationRequest>, JsonRejection>,
) -> Result<(StatusCode, Json<ApiResponse<ConversationResponse>>), ApiError> {
let Json(req) = body.map_err(|e| ApiError::BadRequest(e.to_string()))?;
let conversation = state.conversation_service.create(&user.id, req).await?;
Ok((StatusCode::CREATED, Json(ApiResponse::ok(conversation))))
}Router assembly is done through three layered functions:
create_router()— Async entry point, builds all states then calls the next layercreate_router_with_states()— Allows custom ModuleStates (useful for testing)create_router_with_all_state()— Final assembly, merges all routes and middleware
Middleware stack (outermost to innermost):
CORS (local mode only)
→ Security Headers (all requests)
→ CSRF (non-local mode only)
→ Auth Middleware (selectively applied per route group)
→ Handler
Key points:
- Auth middleware is not global — it is selectively applied per route group via
route_layer() - Public routes (login, status check) do not have auth middleware attached
- The WebSocket
/wsroute does not use HTTP auth middleware — it uses independent token validation callbacks - In local mode, CSRF checking is skipped and a default system user is injected
- AppServices is the sole service construction center — all Repository instantiation and Service assembly happens here
- RouterState holds only necessary dependencies — each domain's State includes only the services it uses
- Dependencies are passed via
Arc<dyn Trait>— enables runtime polymorphism and test substitution - Domain crates do not construct their own dependencies — they only define what they need (RouterState),
aionui-apphandles assembly
CORS (local mode only, allows any origin)
→ Security Headers
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
→ CSRF (non-local mode only, Double Submit Cookie)
→ Auth Middleware (selectively applied per route group)
→ Handler
- Algorithm: HMAC-SHA256
- Validity: 24 hours
- Payload:
user_id,username,iat,exp,iss("aionui"),aud("aionui-webui") - Secret source priority: environment variable → database → random generation (64 bytes, getrandom)
- Token extraction priority:
Authorization: Bearerheader →aionui-sessioncookie - Supports token blacklist (SHA-256 hash, DashMap storage)
Uses the Double Submit Cookie pattern:
- Cookie name:
aionui-csrf-token(not HttpOnly — JavaScript must read it) - Request header:
x-csrf-token - Validation: cookie value must exactly match header value
- Safe methods (GET, HEAD, OPTIONS) bypass validation
- Exempt paths:
/login,/api/auth/qr-login
- Algorithm: bcrypt, cost factor 12
- Timing attack protection: minimum 50ms response time
- User enumeration protection: uses pre-computed dummy hash when user does not exist
| Cookie | HttpOnly | Secure | SameSite | Max-Age |
|---|---|---|---|---|
aionui-session |
✅ | When HTTPS | Strict(HTTPS) / Lax(HTTP) | 30 days |
aionui-csrf-token |
❌ | When HTTPS | Strict(HTTPS) / Lax(HTTP) | 30 days |
| Level | Limit | Window | Scope | Key |
|---|---|---|---|---|
| Auth | 5 failures | 15 minutes | Login routes | Client IP |
| API | 60 requests | 1 minute | Public endpoints | Client IP |
| Action | 20 requests | 1 minute | Sensitive operations | User ID (falls back to IP) |
IP extraction priority: X-Forwarded-For → X-Real-IP → "unknown"
Enabled via the --local startup flag, designed for Electron embedded scenarios:
- Skips JWT verification, injects a fixed user (
system_default_user) - Skips CSRF checking
- Enables fully open CORS
- WebSocket is also exempt from authentication
- New endpoints must be evaluated for auth middleware requirement
- State-changing operations (POST/PUT/DELETE/PATCH) must be CSRF-protected
- Sensitive operations should have rate limiting configured
- Error responses must not leak internal implementation details (DbError::Query maps to generic Internal)
- Secrets must never be hardcoded in source code
| Layer | Location | Database Strategy | Purpose |
|---|---|---|---|
| Unit tests | #[cfg(test)] inline in each .rs file |
None or Mock | Function-level logic verification |
| Integration tests | crates/<crate>/tests/ |
In-memory SQLite | Service and Repository behavior verification |
| E2E tests | crates/aionui-app/tests/ |
In-memory SQLite | Full HTTP request chain verification |
All tests requiring a database use init_database_memory():
- Creates an SQLite in-memory database (
sqlite::memory:) - Single connection pool (
max_connections = 1, ensures data consistency for in-memory DB) - Automatically runs migrations
- Automatically creates the system default user (
system_default_user) - Each test gets an independent, fresh database instance
Prefer real in-memory databases. Mocks are only for isolating unneeded dependencies.
- Integration and E2E tests: use real Sqlite implementations + in-memory database
- Unit tests: mock unrelated dependencies (e.g.,
MockBroadcaster,MockConversationRepo) - Mock implementations use
Mutex<Vec<T>>for in-memory storage with manual trait implementations
aionui-app/tests/common/mod.rs provides shared test utilities:
// Build the complete application
let (app, services) = build_app().await;
// Create a user and log in, obtaining auth credentials
let (token, csrf) = setup_and_login(&services, "testuser", "password").await;
// Make an authenticated request
let response = app.oneshot(
get_with_token("/api/conversations", &token, &csrf)
).await;Login flow:
- Create user directly via Repository (bypassing the API)
- GET
/api/auth/statusto extract the CSRF token - POST
/loginto obtain the session token - Subsequent requests carry
Authorization: Bearer+x-csrf-tokenheaders
| Suffix | Purpose | Example |
|---|---|---|
*_test.rs |
Unit/functional tests | extension_loading_test.rs |
*_integration.rs |
Integration tests | acp_agent_integration.rs |
*_e2e.rs |
End-to-end tests | auth_e2e.rs, conversation_e2e.rs |
When a test fails, do NOT modify the test to make it pass. First determine:
- Test assertion still represents correct behavior → fix the implementation, not the test
- Requirements or interface intentionally changed, test reflects old behavior → may update the test, but must:
- Confirm the change is intentional (not an unintended side effect)
- Ensure new assertions still validate meaningful behavior
- Uncertain → stop, trace back the change, clarify before proceeding
Prohibited:
- ❌ Deleting failing tests to "fix" the problem
- ❌ Weakening specific assertions to vague ones (e.g.,
assert_eq!(status, 201)→assert!(status.is_success()))
Create a new crate when:
- It represents an independent business domain (with its own data models and lifecycle)
- It needs an independent route prefix (
/api/new-domain/...) - It has no strong coupling with existing domains
Extend an existing crate when:
- The feature is a sub-feature of an existing domain
- It shares the same data models
- Routes are sub-paths of an existing prefix
Using aionui-my-feature as an example:
Step 1: Create the crate and register it in the workspace
- Create the directory
crates/aionui-my-feature/ - Add the workspace member in root
Cargo.toml:members = [ # ... existing members "crates/aionui-my-feature", ]
- Register in
[workspace.dependencies]of rootCargo.toml:aionui-my-feature = { path = "crates/aionui-my-feature" }
- Use
.workspace = truefor shared dependency versions within the crate
Step 2: Write the crate following the standard structure
crates/aionui-my-feature/
├── Cargo.toml
├── src/
│ ├── lib.rs # Export my_feature_routes, MyFeatureService, MyFeatureRouterState
│ ├── routes.rs # pub fn my_feature_routes(state: ...) -> Router
│ ├── service.rs # Business logic
│ └── state.rs # #[derive(Clone)] pub struct MyFeatureRouterState { ... }
└── tests/
└── my_feature_test.rs
Step 3: If database access is needed, add to aionui-db
- Add Row model in
models/ - Define Repository trait (
Iprefix) and Sqlite implementation inrepository/ - Add migration file in
migrations/(NNN_descriptive_name.sql)
Step 4: If API types are needed, add to aionui-api-types
Define request/response types in aionui-api-types to keep API contracts centrally managed.
Step 5: Wire into aionui-app
-
Add dependency in
aionui-app/Cargo.toml:aionui-my-feature.workspace = true
-
Add field to
ModuleStates:pub my_feature: MyFeatureRouterState,
-
Write the
build_my_feature_state()function:pub fn build_my_feature_state(services: &AppServices) -> MyFeatureRouterState { let pool = services.database.pool().clone(); let repo = Arc::new(SqliteMyFeatureRepository::new(pool)); MyFeatureRouterState { my_feature_service: MyFeatureService::new(repo, services.event_bus.clone()), } }
-
Call it in
build_module_states():my_feature: build_my_feature_state(services),
-
Register routes in
create_router_with_all_state():let my_feature_authenticated = my_feature_routes(states.my_feature) .route_layer(from_fn_with_state(auth_mw_state.clone(), auth_middleware)); let router = Router::new() // ... existing routes .merge(my_feature_authenticated) // ...
Before adding a new crate, confirm:
- Crate internal structure follows the standard pattern (lib/routes/service/state)
- Dependency direction is correct (does not depend on upper-layer or same-layer concrete implementations)
- Repository trait defined in aionui-db, implementation uses Sqlite prefix
- API types defined in aionui-api-types
- Routes use
/api/prefix with kebab-case resource names - Includes corresponding test files
- WebSocket events follow
domain.camelCaseActionnaming convention
The backend embeds a bun runtime for self-contained distribution. Relevant env vars:
AIONUI_EMBED_BUN=1— enable bun download + embed duringcargo build. Release CI sets this; local dev builds skip it (faster, no network).BUN_VARIANT=default|baseline— select which Linux x64 variant to embed.baselinetargets CPUs without AVX2.AIONUI_BUN_PATH=/abs/path/to/bun— runtime override. When set and pointing to an executable file,resolve_bun()returns it verbatim, skipping the embedded +whichfallback chain. Useful for testing custom bun builds or bisecting bun regressions.
The bun version is pinned in
crates/aionui-runtime/Cargo.toml under
[package.metadata.aionui-runtime] bun_version = "...". Upgrading bun is
a one-line change — no source edits required.
fn main() calls aionui_runtime::enhance_process_path() before the
tokio runtime starts, so every downstream which::which(...) and
Command::new(...) — including the existing spawn sites across the
workspace — inherits an enriched PATH. Three layers are merged in priority
order: bundled bun directory → platform extra bins (~/.bun/bin,
~/.cargo/bin, ~/.local/bin, Windows %APPDATA%\npm, Git, Scoop, …) →
current PATH → login-shell $PATH (Unix, 3 s timeout). The call is
unsafe because Rust 2024 requires a single-threaded precondition for
env::set_var; main() runs this as its very first statement to
satisfy the invariant. A startup: PATH ready path_segments=… path_len=…
info log confirms the enhancement at each run (no full PATH content is
logged at info level).
New subprocess spawn sites should go through
aionui_runtime::Builder::agent(program) (for long-running agent CLIs
whose stdio the caller owns) or aionui_runtime::Builder::clean_cli(program)
(for short-lived tools whose output we parse). Both set
kill_on_drop(true) and strip NODE_OPTIONS/NODE_INSPECT/NODE_DEBUG/
CLAUDECODE so debug-profile env doesn't leak into the child.
clean_cli additionally pipes stdio and sets NO_COLOR=1 + TERM=dumb
to keep ANSI codes out of captured output.
Do NOT manually re-implement these behaviours with raw
tokio::process::Command — the centralised builder is the one place to
update policies (e.g. future CARGO_* cleanup, sandbox flags).