chore: publish clean community baseline
Deploy / deploy (push) Successful in 30s
CI / Rust Checks (push) Successful in 4m59s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 4m8s

This commit is contained in:
github-ops
2026-06-17 06:15:46 +00:00
commit b546063998
307 changed files with 71048 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "admin-api"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[[bin]]
name = "admin-api"
path = "src/main.rs"
[dependencies]
argon2.workspace = true
axum.workspace = true
axum-extra.workspace = true
base64.workspace = true
crank-community-auth = { path = "../../crates/crank-community-auth" }
crank-core = { path = "../../crates/crank-core" }
crank-mapping = { path = "../../crates/crank-mapping" }
crank-registry = { path = "../../crates/crank-registry" }
crank-runtime = { path = "../../crates/crank-runtime" }
crank-schema = { path = "../../crates/crank-schema" }
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
sha2.workspace = true
sqlx.workspace = true
thiserror.workspace = true
time.workspace = true
tokio = { workspace = true, features = ["fs"] }
tracing.workspace = true
tracing-subscriber.workspace = true
uuid.workspace = true
[dev-dependencies]
async-trait = "0.1"
reqwest.workspace = true
serial_test = "3"
+68
View File
@@ -0,0 +1,68 @@
FROM rust:1.85-bookworm AS deps
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY .sqlx ./.sqlx
COPY apps/admin-api/Cargo.toml apps/admin-api/Cargo.toml
COPY apps/mcp-server/Cargo.toml apps/mcp-server/Cargo.toml
COPY crates/crank-core/Cargo.toml crates/crank-core/Cargo.toml
COPY crates/crank-schema/Cargo.toml crates/crank-schema/Cargo.toml
COPY crates/crank-mapping/Cargo.toml crates/crank-mapping/Cargo.toml
COPY crates/crank-registry/Cargo.toml crates/crank-registry/Cargo.toml
COPY crates/crank-runtime/Cargo.toml crates/crank-runtime/Cargo.toml
COPY crates/crank-adapter-rest/Cargo.toml crates/crank-adapter-rest/Cargo.toml
RUN mkdir -p \
apps/admin-api/src \
apps/mcp-server/src \
crates/crank-core/src \
crates/crank-schema/src \
crates/crank-mapping/src \
crates/crank-registry/src \
crates/crank-runtime/src \
crates/crank-adapter-rest/src \
&& printf 'fn main() {}\n' > apps/admin-api/src/main.rs \
&& printf 'fn main() {}\n' > apps/mcp-server/src/main.rs \
&& printf 'pub fn placeholder() {}\n' > crates/crank-core/src/lib.rs \
&& printf 'pub fn placeholder() {}\n' > crates/crank-schema/src/lib.rs \
&& printf 'pub fn placeholder() {}\n' > crates/crank-mapping/src/lib.rs \
&& printf 'pub fn placeholder() {}\n' > crates/crank-registry/src/lib.rs \
&& printf 'pub fn placeholder() {}\n' > crates/crank-runtime/src/lib.rs \
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-rest/src/lib.rs
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/app/target \
SQLX_OFFLINE=true cargo build --release -p admin-api
FROM rust:1.85-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY .sqlx ./.sqlx
COPY apps ./apps
COPY crates ./crates
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/app/target \
SQLX_OFFLINE=true cargo build --release -p admin-api \
&& cp /app/target/release/admin-api /tmp/admin-api
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /tmp/admin-api /usr/local/bin/admin-api
ENV CRANK_ADMIN_BIND=0.0.0.0:3001
EXPOSE 3001
CMD ["admin-api"]
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
use axum::{
extract::{OriginalUri, Request, State},
middleware::Next,
response::Response,
};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use crank_community_auth::{
cleared_session_cookie as build_cleared_session_cookie,
create_session_cookie as build_session_cookie, hash_password as community_hash_password,
session_cookie as build_session_cookie_header,
};
use crank_core::{User, UserSessionId, WorkspaceId};
use crank_registry::WorkspaceMembershipRecord;
use serde::Serialize;
use crate::{error::ApiError, state::AppState};
pub use crank_community_auth::{
SESSION_COOKIE_NAME, SessionCookie, extract_session_token, hash_session_secret, verify_password,
};
#[derive(Clone)]
pub struct BootstrapAdminConfig {
pub email: String,
pub password: String,
pub display_name: String,
}
#[derive(Clone)]
pub struct AuthSettings {
pub session_secret: String,
pub password_pepper: String,
pub session_ttl_hours: i64,
pub cookie_secure: bool,
pub bootstrap_admin: BootstrapAdminConfig,
}
#[derive(Clone, Debug, Serialize)]
pub struct AuthenticatedSession {
pub session_id: UserSessionId,
pub user: User,
pub memberships: Vec<WorkspaceMembershipRecord>,
pub current_workspace_id: Option<WorkspaceId>,
}
pub fn hash_password(password: &str, pepper: &str) -> Result<String, ApiError> {
community_hash_password(password, pepper)
.map_err(|error| ApiError::internal(format!("failed to hash password: {error}")))
}
pub fn create_session_cookie(settings: &AuthSettings) -> Result<SessionCookie, ApiError> {
build_session_cookie(settings.session_ttl_hours)
.map_err(|error| ApiError::internal(format!("failed to create session cookie: {error}")))
}
pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> {
build_session_cookie_header(token, settings.cookie_secure, settings.session_ttl_hours)
}
pub fn cleared_session_cookie(settings: &AuthSettings) -> Cookie<'static> {
build_cleared_session_cookie(settings.cookie_secure)
}
pub async fn require_session(
State(state): State<AppState>,
jar: CookieJar,
mut request: Request,
next: Next,
) -> Result<Response, ApiError> {
let session = resolve_authenticated_session(state.clone(), &jar).await?;
request.extensions_mut().insert(session);
Ok(next.run(request).await)
}
pub async fn require_workspace_session(
State(state): State<AppState>,
jar: CookieJar,
original_uri: OriginalUri,
mut request: Request,
next: Next,
) -> Result<Response, ApiError> {
let session = resolve_authenticated_session(state.clone(), &jar).await?;
let workspace_id = workspace_id_from_path(original_uri.path())
.ok_or_else(|| ApiError::internal("workspace middleware was applied to an invalid path"))?;
let has_access = state
.service
.user_has_workspace_access(&session.user.id, &workspace_id)
.await?;
if !has_access {
return Err(ApiError::forbidden("workspace access denied"));
}
request.extensions_mut().insert(session);
Ok(next.run(request).await)
}
async fn resolve_authenticated_session(
state: AppState,
jar: &CookieJar,
) -> Result<AuthenticatedSession, ApiError> {
let (session_id, session_value) = extract_session_token(jar)
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
let session = state
.service
.get_session(&session_id, &session_value)
.await?
.ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?;
state.service.touch_session(&session_id).await?;
Ok(session)
}
fn workspace_id_from_path(path: &str) -> Option<WorkspaceId> {
let marker = "/api/admin/workspaces/";
let (_, suffix) = path.split_once(marker)?;
let workspace_id = suffix.split('/').next()?;
if workspace_id.is_empty() {
return None;
}
Some(WorkspaceId::new(workspace_id))
}
+571
View File
@@ -0,0 +1,571 @@
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use crank_mapping::MappingError;
use crank_registry::RegistryError;
use crank_runtime::RuntimeError;
use crank_schema::SchemaError;
use serde_json::{Value, json};
use thiserror::Error;
use tracing::{error, warn};
use crate::storage::StorageError;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("{message}")]
Unauthorized {
message: String,
context: Option<Value>,
},
#[error("{message}")]
Forbidden {
message: String,
context: Option<Value>,
},
#[error("{message}")]
Validation {
message: String,
context: Option<Value>,
},
#[error("{message}")]
NotFound {
message: String,
context: Option<Value>,
},
#[error("{message}")]
Conflict {
message: String,
context: Option<Value>,
},
#[error("{message}")]
RateLimited {
message: String,
context: Option<Value>,
},
#[error("{message}")]
Internal {
message: String,
context: Option<Value>,
},
}
impl ApiError {
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::Unauthorized {
message: message.into(),
context: None,
}
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::Forbidden {
message: message.into(),
context: None,
}
}
pub fn validation(message: impl Into<String>) -> Self {
Self::Validation {
message: message.into(),
context: None,
}
}
pub fn internal(message: impl Into<String>) -> Self {
Self::Internal {
message: message.into(),
context: None,
}
}
pub(crate) fn rate_limited_with_context(message: impl Into<String>, context: Value) -> Self {
Self::RateLimited {
message: message.into(),
context: Some(context),
}
}
pub(crate) fn validation_with_context(message: impl Into<String>, context: Value) -> Self {
Self::Validation {
message: message.into(),
context: Some(context),
}
}
pub(crate) fn not_found_with_context(message: impl Into<String>, context: Value) -> Self {
Self::NotFound {
message: message.into(),
context: Some(context),
}
}
pub(crate) fn conflict_with_context(message: impl Into<String>, context: Value) -> Self {
Self::Conflict {
message: message.into(),
context: Some(context),
}
}
pub(crate) fn forbidden_with_context(message: impl Into<String>, context: Value) -> Self {
Self::Forbidden {
message: message.into(),
context: Some(context),
}
}
fn status_code(&self) -> StatusCode {
match self {
Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
Self::Forbidden { .. } => StatusCode::FORBIDDEN,
Self::Validation { .. } => StatusCode::BAD_REQUEST,
Self::NotFound { .. } => StatusCode::NOT_FOUND,
Self::Conflict { .. } => StatusCode::CONFLICT,
Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn code(&self) -> &'static str {
match self {
Self::Unauthorized { .. } => "unauthorized",
Self::Forbidden { .. } => "forbidden",
Self::Validation { .. } => "validation_error",
Self::NotFound { .. } => "not_found",
Self::Conflict { .. } => "conflict",
Self::RateLimited { .. } => "rate_limited",
Self::Internal { .. } => "internal_error",
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
match &self {
Self::Internal { message, .. } => {
error!(error_code = self.code(), error_message = %message)
}
Self::Unauthorized { message, .. }
| Self::Forbidden { message, .. }
| Self::Validation { message, .. }
| Self::NotFound { message, .. }
| Self::Conflict { message, .. }
| Self::RateLimited { message, .. } => {
warn!(error_code = self.code(), error_message = %message)
}
}
let mut error = json!({
"code": self.code(),
"message": self.to_string(),
});
if let Some(context) = self.context() {
error["context"] = context;
}
let body = Json(json!({
"error": error
}));
(self.status_code(), body).into_response()
}
}
impl ApiError {
fn context(&self) -> Option<Value> {
match self {
Self::Unauthorized { context, .. }
| Self::Forbidden { context, .. }
| Self::Validation { context, .. }
| Self::NotFound { context, .. }
| Self::Conflict { context, .. }
| Self::RateLimited { context, .. }
| Self::Internal { context, .. } => context.clone(),
}
}
}
impl From<RegistryError> for ApiError {
fn from(value: RegistryError) -> Self {
match value {
RegistryError::WorkspaceNotFound { workspace_id } => Self::not_found_with_context(
format!("workspace {workspace_id} was not found"),
json!({ "workspace_id": workspace_id }),
),
RegistryError::UserNotFound { user_id } => Self::not_found_with_context(
format!("user {user_id} was not found"),
json!({ "user_id": user_id }),
),
RegistryError::MembershipNotFound {
workspace_id,
user_id,
} => Self::not_found_with_context(
format!("membership for user {user_id} in workspace {workspace_id} was not found"),
json!({
"workspace_id": workspace_id,
"user_id": user_id,
}),
),
RegistryError::AgentNotFound { agent_id } => Self::not_found_with_context(
format!("agent {agent_id} was not found"),
json!({ "agent_id": agent_id }),
),
RegistryError::InvitationNotFound { invitation_id } => Self::not_found_with_context(
format!("invitation {invitation_id} was not found"),
json!({ "invitation_id": invitation_id }),
),
RegistryError::PlatformApiKeyNotFound { key_id } => Self::not_found_with_context(
format!("platform api key {key_id} was not found"),
json!({ "key_id": key_id }),
),
RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context(
format!("secret {secret_id} was not found"),
json!({ "secret_id": secret_id }),
),
RegistryError::StreamSessionNotFound { session_id } => Self::not_found_with_context(
format!("stream session {session_id} was not found"),
json!({ "session_id": session_id }),
),
RegistryError::AsyncJobNotFound { job_id } => Self::not_found_with_context(
format!("async job {job_id} was not found"),
json!({ "job_id": job_id }),
),
RegistryError::InvocationLogNotFound { log_id } => Self::not_found_with_context(
format!("invocation log {log_id} was not found"),
json!({ "log_id": log_id }),
),
RegistryError::PublishedAgentNotFound {
workspace_slug,
agent_slug,
} => Self::not_found_with_context(
format!("published agent {workspace_slug}/{agent_slug} was not found"),
json!({
"workspace_slug": workspace_slug,
"agent_slug": agent_slug,
}),
),
RegistryError::OperationNotFound { operation_id } => Self::not_found_with_context(
format!("operation {operation_id} was not found"),
json!({ "operation_id": operation_id }),
),
RegistryError::OperationVersionNotFound {
operation_id,
version,
} => Self::not_found_with_context(
format!("operation version {version} for {operation_id} was not found"),
json!({
"operation_id": operation_id,
"version": version,
}),
),
RegistryError::OperationHasPublishedAgentBindings { operation_id } => {
Self::conflict_with_context(
format!(
"operation {operation_id} cannot be deleted while it is bound to a published agent"
),
json!({ "operation_id": operation_id }),
)
}
RegistryError::AuthProfileNotFound { auth_profile_id } => Self::not_found_with_context(
format!("auth profile {auth_profile_id} was not found"),
json!({ "auth_profile_id": auth_profile_id }),
),
RegistryError::OperationAlreadyExists { operation_id } => Self::conflict_with_context(
format!("operation {operation_id} already exists"),
json!({ "operation_id": operation_id }),
),
RegistryError::WorkspaceSlugAlreadyExists { slug } => Self::conflict_with_context(
format!("workspace with slug {slug} already exists"),
json!({ "slug": slug }),
),
RegistryError::SecretNameAlreadyExists { workspace_id, name } => {
Self::conflict_with_context(
format!("secret with name {name} already exists in workspace {workspace_id}"),
json!({
"workspace_id": workspace_id,
"name": name,
}),
)
}
RegistryError::SecretReferencedByAuthProfile {
secret_id,
auth_profile_id,
} => Self::conflict_with_context(
format!("secret {secret_id} is referenced by auth profile {auth_profile_id}"),
json!({
"secret_id": secret_id,
"auth_profile_id": auth_profile_id,
}),
),
RegistryError::InvalidStreamSessionTransition {
session_id,
from,
to,
} => Self::conflict_with_context(
format!("invalid stream session transition for {session_id}: {from} -> {to}"),
json!({
"session_id": session_id,
"from": from,
"to": to,
}),
),
RegistryError::InvalidAsyncJobTransition { job_id, from, to } => {
Self::conflict_with_context(
format!("invalid async job transition for {job_id}: {from} -> {to}"),
json!({
"job_id": job_id,
"from": from,
"to": to,
}),
)
}
RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context(
format!("user with email {email} already exists"),
json!({ "email": email }),
),
RegistryError::InvalidInitialVersion {
operation_id,
version,
} => Self::validation_with_context(
format!("operation {operation_id} must start with version 1, got {version}"),
json!({
"operation_id": operation_id,
"version": version,
}),
),
RegistryError::InvalidVersionSequence {
operation_id,
expected,
actual,
} => Self::validation_with_context(
format!("operation {operation_id} expected next version {expected}, got {actual}"),
json!({
"operation_id": operation_id,
"expected": expected,
"actual": actual,
}),
),
RegistryError::ImmutableOperationFieldChanged {
operation_id,
field,
} => Self::validation_with_context(
format!("operation {operation_id} changed immutable field {field}"),
json!({
"operation_id": operation_id,
"field": field,
}),
),
RegistryError::InvalidEnumRepresentation { field } => Self::validation_with_context(
format!("unsupported enum representation for field {field}"),
json!({ "field": field }),
),
RegistryError::InvalidNumericValue { field, value } => Self::validation_with_context(
format!("invalid numeric value for field {field}: {value}"),
json!({
"field": field,
"value": value,
}),
),
RegistryError::YamlImportJobNotFound { job_id } => Self::validation_with_context(
format!("yaml import job {job_id} was not found"),
json!({ "job_id": job_id }),
),
RegistryError::Storage(_) | RegistryError::Serialization(_) => {
Self::internal(value.to_string())
}
}
}
}
impl From<MappingError> for ApiError {
fn from(value: MappingError) -> Self {
Self::validation(value.to_string())
}
}
impl From<SchemaError> for ApiError {
fn from(value: SchemaError) -> Self {
Self::validation(value.to_string())
}
}
impl From<StorageError> for ApiError {
fn from(value: StorageError) -> Self {
match value {
StorageError::InvalidStorageRef { details } => Self::validation_with_context(
format!("invalid storage reference: {details}"),
json!({ "details": details }),
),
StorageError::Io(_) | StorageError::Serialization(_) => {
Self::internal(value.to_string())
}
}
}
}
pub fn runtime_test_failure(error: &RuntimeError) -> Value {
let mut payload = json!({
"code": runtime_test_failure_code(error),
"message": error.to_string()
});
if let Some(context) = runtime_error_context(error) {
payload["context"] = context;
}
payload
}
fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
match error {
RuntimeError::Schema(_) => "runtime_schema_error",
RuntimeError::Mapping(_) => "runtime_mapping_error",
RuntimeError::GraphqlAdapter(_) => "runtime_graphql_error",
RuntimeError::GrpcAdapter(_) => "runtime_grpc_error",
RuntimeError::RestAdapter(_) => "runtime_rest_error",
RuntimeError::ProtocolAdapter(_) => "runtime_adapter_error",
RuntimeError::SoapAdapter(_) => "runtime_soap_error",
RuntimeError::WebsocketAdapter(_) => "runtime_websocket_error",
RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error",
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error",
RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error",
RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error",
RuntimeError::InvalidStreamingPayload { .. } => "runtime_streaming_payload_error",
RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error",
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
"runtime_secret_error"
}
RuntimeError::InvalidAuthSecretValue { .. } => "runtime_secret_value_error",
RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error",
}
}
pub fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
match error {
RuntimeError::InvalidPreparedRequest { field, reason } => Some(json!({
"field": field,
"reason": reason,
})),
RuntimeError::InvalidStreamingPayload { field, reason } => Some(json!({
"field": field,
"reason": reason,
})),
RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({
"secret_id": secret_id,
"reason": reason,
})),
RuntimeError::SecretCrypto { operation, details } => Some(json!({
"operation": operation,
"details": details,
})),
RuntimeError::MissingAuthProfile { auth_profile_id } => Some(json!({
"auth_profile_id": auth_profile_id,
})),
RuntimeError::MissingSecret { secret_id } => Some(json!({
"secret_id": secret_id,
})),
RuntimeError::MissingSecretVersion { secret_id, version } => Some(json!({
"secret_id": secret_id,
"version": version,
})),
RuntimeError::MissingStreamingConfig { operation_id } => Some(json!({
"operation_id": operation_id,
})),
RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({
"operation_id": operation_id,
"mode": mode,
})),
RuntimeError::UnsupportedProtocol { protocol } => Some(json!({
"protocol": protocol,
})),
RuntimeError::ConcurrencyLimitExceeded { kind, limit } => Some(json!({
"kind": kind,
"limit": limit,
})),
_ => None,
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{ApiError, runtime_error_context, runtime_test_failure};
use crank_registry::RegistryError;
use crank_runtime::RuntimeError;
#[test]
fn runtime_test_failure_includes_structured_context() {
let payload = runtime_test_failure(&RuntimeError::InvalidPreparedRequest {
field: "request.headers".to_owned(),
reason: "must be an object".to_owned(),
});
assert_eq!(payload["code"], "runtime_request_error");
assert_eq!(
payload["context"],
json!({
"field": "request.headers",
"reason": "must be an object"
})
);
}
#[test]
fn runtime_error_context_includes_secret_crypto_operation() {
let context = runtime_error_context(&RuntimeError::SecretCrypto {
operation: "decode secret envelope",
details: "bad base64".to_owned(),
})
.unwrap();
assert_eq!(
context,
json!({
"operation": "decode secret envelope",
"details": "bad base64"
})
);
}
#[test]
fn runtime_test_failure_includes_runtime_overload_context() {
let payload = runtime_test_failure(&RuntimeError::ConcurrencyLimitExceeded {
kind: "window",
limit: 16,
});
assert_eq!(payload["code"], "runtime_overloaded");
assert_eq!(
payload["context"],
json!({
"kind": "window",
"limit": 16
})
);
}
#[test]
fn registry_errors_preserve_structured_context_in_api_error() {
let error = ApiError::from(RegistryError::InvalidAsyncJobTransition {
job_id: "job_123".to_owned(),
from: "running".to_owned(),
to: "completed".to_owned(),
});
match error {
ApiError::Conflict { context, .. } => {
assert_eq!(
context,
Some(json!({
"job_id": "job_123",
"from": "running",
"to": "completed"
}))
);
}
other => panic!("unexpected error variant: {other:?}"),
}
}
}
+9
View File
@@ -0,0 +1,9 @@
pub mod app;
pub mod auth;
pub mod error;
pub mod rate_limit;
pub mod request_context;
pub mod routes;
pub mod service;
pub mod state;
pub mod storage;
+156
View File
@@ -0,0 +1,156 @@
use std::{env, net::SocketAddr, path::PathBuf};
use admin_api::{
app::build_app,
auth::{AuthSettings, BootstrapAdminConfig},
service::AdminServiceBuilder,
state::AppState,
};
use crank_community_auth::PasswordIdentityProvider;
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
use crank_runtime::{
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
RuntimeLimits, SecretCrypto, community_default,
};
use sqlx::postgres::PgConnectOptions;
use tokio::net::TcpListener;
use tracing::info;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_env_filter(
env::var("CRANK_LOG_LEVEL").unwrap_or_else(|_| "admin_api=info,tower_http=info".into()),
)
.init();
let storage_root = PathBuf::from(
env::var("CRANK_STORAGE_ROOT").unwrap_or_else(|_| "/var/lib/crank/storage".into()),
);
let bind_addr = env::var("CRANK_ADMIN_BIND").unwrap_or_else(|_| "0.0.0.0:3001".into());
let base_url = env::var("CRANK_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into());
let socket_addr: SocketAddr = bind_addr.parse()?;
let pool_config = PostgresPoolConfig::from_env()?;
let registry = PostgresRegistry::connect_with_options_and_pool_config(
database_options_from_env()?,
pool_config,
)
.await?;
let auth_settings = AuthSettings {
session_secret: env::var("CRANK_SESSION_SECRET")?,
password_pepper: env::var("CRANK_PASSWORD_PEPPER")?,
session_ttl_hours: env::var("CRANK_SESSION_TTL_HOURS")
.ok()
.and_then(|value| value.parse::<i64>().ok())
.unwrap_or(24),
cookie_secure: base_url.starts_with("https://"),
bootstrap_admin: BootstrapAdminConfig {
email: env::var("CRANK_BOOTSTRAP_ADMIN_EMAIL")?,
password: env::var("CRANK_BOOTSTRAP_ADMIN_PASSWORD")?,
display_name: env::var("CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME")
.unwrap_or_else(|_| "Crank Owner".into()),
},
};
let runtime_limits = RuntimeLimits::from_env()?;
let cache_config = RuntimeCacheConfig::from_env()?;
let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
let api_rate_limit = admin_api_rate_limit_config_from_env()?;
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
let runtime = community_default()
.with_limits(runtime_limits)
.with_response_cache(cache_stores.response.clone())
.build();
let identity_provider =
PasswordIdentityProvider::new(registry.clone(), auth_settings.password_pepper.clone());
let service = AdminServiceBuilder::new(
registry,
storage_root,
auth_settings,
secret_crypto,
runtime,
)
.with_identity_provider(std::sync::Arc::new(identity_provider))
.build();
service.bootstrap_admin_user().await?;
if env_flag("CRANK_DEMO_SEED") {
service.seed_demo_assets().await?;
}
let state = AppState {
service,
api_rate_limiter: if cache_config.backend.is_external() {
RequestRateLimiter::new_shared(api_rate_limit, cache_stores.rate_limit.clone())
} else {
RequestRateLimiter::new(api_rate_limit)
},
};
let app = build_app(state);
let listener = TcpListener::bind(socket_addr).await?;
info!(
runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary,
runtime_max_concurrent_window = runtime_limits.max_concurrent_window,
runtime_max_concurrent_sessions = runtime_limits.max_concurrent_sessions,
runtime_max_concurrent_jobs = runtime_limits.max_concurrent_jobs,
admin_rate_limit_rps = api_rate_limit.requests_per_second,
admin_rate_limit_burst = api_rate_limit.burst,
cache_backend = %cache_config.backend,
max_connections = pool_config.max_connections,
min_connections = pool_config.min_connections,
acquire_timeout_ms = pool_config.acquire_timeout_ms,
idle_timeout_ms = pool_config.idle_timeout_ms,
max_lifetime_ms = pool_config.max_lifetime_ms,
"postgres pool configured"
);
info!("admin-api listening on {}", socket_addr);
axum::serve(listener, app).await?;
Ok(())
}
fn env_flag(name: &str) -> bool {
matches!(
env::var(name)
.ok()
.as_deref()
.map(str::to_ascii_lowercase)
.as_deref(),
Some("1" | "true" | "yes" | "on")
)
}
fn admin_api_rate_limit_config_from_env()
-> Result<RequestRateLimitConfig, Box<dyn std::error::Error>> {
let requests_per_second = env::var("CRANK_ADMIN_RATE_LIMIT_RPS")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(30);
let burst = env::var("CRANK_ADMIN_RATE_LIMIT_BURST")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(60);
Ok(RequestRateLimitConfig::new(requests_per_second, burst)?)
}
fn database_options_from_env() -> Result<PgConnectOptions, Box<dyn std::error::Error>> {
if let Ok(database_url) = env::var("CRANK_DATABASE_URL") {
return Ok(database_url.parse::<PgConnectOptions>()?);
}
let host = env::var("POSTGRES_HOST").unwrap_or_else(|_| "postgres".into());
let port = env::var("POSTGRES_PORT")
.ok()
.and_then(|value| value.parse::<u16>().ok())
.unwrap_or(5432);
let database = env::var("POSTGRES_DB").unwrap_or_else(|_| "crank".into());
let username = env::var("POSTGRES_USER").unwrap_or_else(|_| "crank".into());
let password = env::var("POSTGRES_PASSWORD").unwrap_or_else(|_| "crank".into());
Ok(PgConnectOptions::new()
.host(&host)
.port(port)
.database(&database)
.username(&username)
.password(&password))
}
+106
View File
@@ -0,0 +1,106 @@
use axum::{
extract::{Request, State},
http::header::{COOKIE, HeaderMap},
middleware::Next,
response::Response,
};
use crank_runtime::RateLimitRejection;
use crate::{auth::SESSION_COOKIE_NAME, error::ApiError, state::AppState};
pub async fn apply_api_rate_limit(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Result<Response, ApiError> {
let key = rate_limit_key(request.headers(), request.uri().path());
if let Err(rejection) = state.api_rate_limiter.check(&key).await {
return Err(ApiError::rate_limited_with_context(
"request rate limit exceeded",
rejection_context(rejection),
));
}
Ok(next.run(request).await)
}
fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value {
serde_json::json!({
"retry_after_ms": rejection.retry_after_ms,
})
}
fn rate_limit_key(headers: &HeaderMap, path: &str) -> String {
if let Some(session_id) = session_id_from_headers(headers) {
return format!("session:{session_id}");
}
if let Some(forwarded_for) = header_value(headers, "x-forwarded-for") {
let ip = forwarded_for
.split(',')
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("unknown");
return format!("ip:{ip}");
}
if let Some(real_ip) = header_value(headers, "x-real-ip") {
return format!("ip:{real_ip}");
}
format!("anonymous:{path}")
}
fn session_id_from_headers(headers: &HeaderMap) -> Option<String> {
let cookies = headers.get(COOKIE)?.to_str().ok()?;
for part in cookies.split(';') {
let (name, value) = part.trim().split_once('=')?;
if name != SESSION_COOKIE_NAME {
continue;
}
let (session_id, _) = value.split_once('.')?;
if !session_id.is_empty() {
return Some(session_id.to_owned());
}
}
None
}
fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
headers.get(name)?.to_str().ok().map(str::trim)
}
#[cfg(test)]
mod tests {
use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
use super::rate_limit_key;
#[test]
fn keys_by_session_cookie_first() {
let mut headers = HeaderMap::new();
headers.insert(
COOKIE,
HeaderValue::from_static("theme=dark; crank_session=sess_123.secret_456"),
);
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
assert_eq!(
rate_limit_key(&headers, "/api/auth/login"),
"session:sess_123"
);
}
#[test]
fn falls_back_to_forwarded_ip() {
let mut headers = HeaderMap::new();
headers.insert(
"x-forwarded-for",
HeaderValue::from_static("10.0.0.5, 10.0.0.6"),
);
assert_eq!(rate_limit_key(&headers, "/api/auth/login"), "ip:10.0.0.5");
}
}
+165
View File
@@ -0,0 +1,165 @@
use axum::{
extract::Request,
http::{HeaderMap, HeaderName, HeaderValue},
middleware::Next,
response::Response,
};
use tracing::info;
use uuid::Uuid;
pub const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
const MAX_REQUEST_ID_LEN: usize = 128;
#[derive(Clone, Debug)]
pub struct RequestContext {
pub request_id: String,
}
pub async fn apply_request_context(mut request: Request, next: Next) -> Response {
let context = RequestContext {
request_id: resolve_request_id(request.headers()),
};
let method = request.method().clone();
let path = request.uri().path().to_owned();
request.extensions_mut().insert(context.clone());
let mut response = next.run(request).await;
info!(
request_id = %context.request_id,
method = %method,
path,
status = response.status().as_u16(),
"admin request completed"
);
if let Ok(value) = HeaderValue::from_str(&context.request_id) {
response.headers_mut().insert(REQUEST_ID_HEADER, value);
}
response
}
fn resolve_request_id(headers: &HeaderMap) -> String {
headers
.get(&REQUEST_ID_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| is_valid_request_id(value))
.map(ToOwned::to_owned)
.unwrap_or_else(|| Uuid::now_v7().to_string())
}
fn is_valid_request_id(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_REQUEST_ID_LEN
&& value
.bytes()
.all(|byte| matches!(byte, 0x21..=0x7e) && byte != b',' && byte != b';')
}
#[cfg(test)]
mod tests {
use std::io;
use std::sync::{Arc, Mutex};
use axum::{Router, routing::get};
use reqwest::Client;
use tokio::net::TcpListener;
use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*};
use super::{REQUEST_ID_HEADER, apply_request_context, is_valid_request_id};
#[test]
fn accepts_visible_ascii_request_ids() {
assert!(is_valid_request_id("req_test_123"));
assert!(is_valid_request_id("trace-123/abc"));
}
#[test]
fn rejects_empty_or_control_request_ids() {
assert!(!is_valid_request_id(""));
assert!(!is_valid_request_id("bad value"));
assert!(!is_valid_request_id("bad\nvalue"));
}
#[derive(Clone, Default)]
struct SharedLogWriter {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl SharedLogWriter {
fn output(&self) -> String {
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
}
}
impl<'a> MakeWriter<'a> for SharedLogWriter {
type Writer = SharedLogGuard;
fn make_writer(&'a self) -> Self::Writer {
SharedLogGuard {
buffer: Arc::clone(&self.buffer),
}
}
}
struct SharedLogGuard {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl io::Write for SharedLogGuard {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.buffer.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn logs_request_completion_with_request_id() {
let writer = SharedLogWriter::default();
let subscriber = tracing_subscriber::registry().with(
tracing_subscriber::fmt::layer()
.with_writer(writer.clone())
.without_time()
.with_ansi(false)
.with_target(false)
.compact()
.with_filter(LevelFilter::INFO),
);
let dispatch = tracing::Dispatch::new(subscriber);
let app = Router::new()
.route("/probe", get(|| async { "ok" }))
.layer(axum::middleware::from_fn(apply_request_context));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let _guard = tracing::dispatcher::set_default(&dispatch);
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let response = Client::new()
.get(format!("http://{address}/probe"))
.header(REQUEST_ID_HEADER.as_str(), "req_admin_trace_123")
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
assert_eq!(
response.headers()[REQUEST_ID_HEADER.as_str()]
.to_str()
.unwrap(),
"req_admin_trace_123"
);
let logs = writer.output();
assert!(logs.contains("admin request completed"));
assert!(logs.contains("req_admin_trace_123"));
assert!(logs.contains("GET"));
assert!(logs.contains("/probe"));
assert!(logs.contains("status=200"));
}
}
+21
View File
@@ -0,0 +1,21 @@
pub mod access;
pub mod agents;
pub mod auth;
pub mod auth_profiles;
pub mod capabilities;
pub mod machine_auth;
pub mod observability;
pub mod operations;
pub mod secrets;
pub mod streaming;
pub mod workspaces;
use axum::Json;
use serde_json::json;
pub async fn health() -> Json<serde_json::Value> {
Json(json!({
"service": "admin-api",
"status": "ok"
}))
}
+287
View File
@@ -0,0 +1,287 @@
use axum::{
Extension, Json,
extract::{Path, State},
http::StatusCode,
};
use crank_core::{
AuditActor, AuditEvent, AuditEventId, AuditTarget, AuditTargetKind, PolicyAction,
PolicyDecision, PolicyScope, SessionActor,
};
use serde::Deserialize;
use serde_json::{Value, json};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::{
auth::AuthenticatedSession,
error::ApiError,
service::{InvitationPayload, UpdateMembershipPayload},
state::AppState,
};
#[derive(Deserialize)]
pub struct WorkspacePath {
pub workspace_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceInvitationPath {
pub workspace_id: String,
pub invitation_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceMembershipPath {
pub workspace_id: String,
pub user_id: String,
}
pub async fn list_memberships(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.list_memberships(&path.workspace_id.as_str().into())
.await?;
Ok(Json(json!({ "items": items })))
}
pub async fn update_membership(
Path(path): Path<WorkspaceMembershipPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Json(payload): Json<UpdateMembershipPayload>,
) -> Result<Json<Value>, ApiError> {
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
enforce_workspace_policy(
&state,
&session,
&workspace_id,
PolicyAction::WriteWorkspaceAccess,
)?;
let items = state
.service
.update_membership_role(
&workspace_id,
&session.user.id,
&path.user_id.as_str().into(),
payload,
)
.await?;
record_access_audit(
&state,
&session,
&workspace_id,
"membership.role_updated",
AuditTargetKind::Membership,
path.user_id.clone(),
json!({ "user_id": path.user_id }),
)
.await?;
Ok(Json(json!({ "items": items })))
}
pub async fn delete_membership(
Path(path): Path<WorkspaceMembershipPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
) -> Result<StatusCode, ApiError> {
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
enforce_workspace_policy(
&state,
&session,
&workspace_id,
PolicyAction::WriteWorkspaceAccess,
)?;
state
.service
.remove_membership(
&workspace_id,
&session.user.id,
&path.user_id.as_str().into(),
)
.await?;
record_access_audit(
&state,
&session,
&workspace_id,
"membership.removed",
AuditTargetKind::Membership,
path.user_id.clone(),
json!({ "user_id": path.user_id }),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn list_invitations(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.list_invitations(&path.workspace_id.as_str().into())
.await?;
Ok(Json(json!({ "items": items })))
}
pub async fn create_invitation(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Json(payload): Json<InvitationPayload>,
) -> Result<Json<Value>, ApiError> {
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
enforce_workspace_policy(
&state,
&session,
&workspace_id,
PolicyAction::WriteWorkspaceAccess,
)?;
let created = state
.service
.create_invitation(&workspace_id, payload)
.await?;
record_access_audit(
&state,
&session,
&workspace_id,
"invitation.created",
AuditTargetKind::Invitation,
created.invitation.invitation.id.as_str().to_owned(),
json!({ "invitation_id": created.invitation.invitation.id.as_str() }),
)
.await?;
Ok(Json(json!(created)))
}
pub async fn delete_invitation(
Path(path): Path<WorkspaceInvitationPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
) -> Result<StatusCode, ApiError> {
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
enforce_workspace_policy(
&state,
&session,
&workspace_id,
PolicyAction::WriteWorkspaceAccess,
)?;
state
.service
.delete_invitation(&workspace_id, &path.invitation_id.as_str().into())
.await?;
record_access_audit(
&state,
&session,
&workspace_id,
"invitation.deleted",
AuditTargetKind::Invitation,
path.invitation_id.clone(),
json!({ "invitation_id": path.invitation_id }),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn export_workspace(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let exported = state
.service
.export_workspace(&path.workspace_id.as_str().into())
.await?;
Ok(Json(json!(exported)))
}
pub async fn delete_workspace(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
) -> Result<StatusCode, ApiError> {
let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into();
enforce_workspace_policy(
&state,
&session,
&workspace_id,
PolicyAction::WriteWorkspace,
)?;
state
.service
.delete_workspace(&workspace_id, &session.user.id)
.await?;
record_access_audit(
&state,
&session,
&workspace_id,
"workspace.deleted",
AuditTargetKind::Workspace,
workspace_id.as_str().to_owned(),
json!({ "workspace_id": workspace_id.as_str() }),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
fn enforce_workspace_policy(
state: &AppState,
session: &AuthenticatedSession,
workspace_id: &crank_core::WorkspaceId,
action: PolicyAction,
) -> Result<(), ApiError> {
let membership = session
.memberships
.iter()
.find(|membership| membership.workspace.id == *workspace_id)
.ok_or_else(|| ApiError::forbidden("workspace access denied"))?;
let actor = SessionActor {
user_id: session.user.id.clone(),
workspace_id: workspace_id.clone(),
role: membership.role,
};
match state.service.policy_engine().check(
&actor,
action,
PolicyScope::Workspace(workspace_id.clone()),
) {
PolicyDecision::Allow => Ok(()),
PolicyDecision::Deny { reason } => Err(ApiError::forbidden(reason)),
}
}
async fn record_access_audit(
state: &AppState,
session: &AuthenticatedSession,
workspace_id: &crank_core::WorkspaceId,
action: &str,
target_kind: AuditTargetKind,
target_id: String,
payload: Value,
) -> Result<(), ApiError> {
state
.service
.audit_sink()
.record(AuditEvent {
id: AuditEventId::new(format!("audit_{}", Uuid::now_v7().simple())),
occurred_at: OffsetDateTime::now_utc(),
actor: AuditActor {
user_id: session.user.id.clone(),
email: session.user.email.clone(),
session_id: Some(session.session_id.clone()),
},
action: action.to_owned(),
target: AuditTarget {
workspace_id: workspace_id.clone(),
kind: target_kind,
id: target_id,
},
payload,
source_ip: None,
user_agent: None,
})
.await
.map_err(|error| ApiError::internal(format!("failed to record audit event: {error}")))
}
+242
View File
@@ -0,0 +1,242 @@
use axum::{
Json,
extract::{Path, State},
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
error::ApiError,
service::{
AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
UpdateAgentPayload,
},
state::AppState,
};
#[derive(Deserialize)]
pub struct WorkspacePath {
pub workspace_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceAgentPath {
pub workspace_id: String,
pub agent_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceAgentVersionPath {
pub workspace_id: String,
pub agent_id: String,
pub version: u32,
}
#[derive(Deserialize)]
pub struct WorkspaceAgentPlatformApiKeyPath {
pub workspace_id: String,
pub agent_id: String,
pub key_id: String,
}
pub async fn list_agents(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.list_agents(&path.workspace_id.as_str().into())
.await?;
Ok(Json(json!({ "items": items })))
}
pub async fn create_agent(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Json(payload): Json<AgentPayload>,
) -> Result<Json<Value>, ApiError> {
let created = state
.service
.create_agent(&path.workspace_id.as_str().into(), payload)
.await?;
Ok(Json(json!(created)))
}
pub async fn get_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let agent = state
.service
.get_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
)
.await?;
Ok(Json(json!(agent)))
}
pub async fn update_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
Json(payload): Json<UpdateAgentPayload>,
) -> Result<Json<Value>, ApiError> {
let updated = state
.service
.update_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(updated)))
}
pub async fn delete_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let deleted = state
.service
.delete_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
)
.await?;
Ok(Json(json!(deleted)))
}
pub async fn get_agent_version(
Path(path): Path<WorkspaceAgentVersionPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let version = state
.service
.get_agent_version(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
path.version,
)
.await?;
Ok(Json(json!(version)))
}
pub async fn save_agent_bindings(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
Json(payload): Json<Vec<AgentBindingPayload>>,
) -> Result<Json<Value>, ApiError> {
let record = state
.service
.save_agent_bindings(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(record)))
}
pub async fn publish_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
Json(payload): Json<PublishPayload>,
) -> Result<Json<Value>, ApiError> {
let published = state
.service
.publish_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
payload.version,
)
.await?;
Ok(Json(json!(published)))
}
pub async fn unpublish_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let updated = state
.service
.unpublish_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
)
.await?;
Ok(Json(json!(updated)))
}
pub async fn archive_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let updated = state
.service
.archive_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
)
.await?;
Ok(Json(json!(updated)))
}
pub async fn list_agent_platform_api_keys(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.list_agent_platform_api_keys(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
)
.await?;
Ok(Json(json!({ "items": items })))
}
pub async fn create_agent_platform_api_key(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
Json(payload): Json<PlatformApiKeyPayload>,
) -> Result<Json<Value>, ApiError> {
let created = state
.service
.create_agent_platform_api_key(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(created)))
}
pub async fn revoke_agent_platform_api_key(
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
State(state): State<AppState>,
) -> Result<axum::http::StatusCode, ApiError> {
state
.service
.revoke_agent_platform_api_key(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
&path.key_id.as_str().into(),
)
.await?;
Ok(axum::http::StatusCode::NO_CONTENT)
}
pub async fn delete_agent_platform_api_key(
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
State(state): State<AppState>,
) -> Result<axum::http::StatusCode, ApiError> {
state
.service
.delete_agent_platform_api_key(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
&path.key_id.as_str().into(),
)
.await?;
Ok(axum::http::StatusCode::NO_CONTENT)
}
+109
View File
@@ -0,0 +1,109 @@
use axum::{Extension, Json, extract::State, http::StatusCode, response::IntoResponse};
use axum_extra::extract::cookie::CookieJar;
use serde_json::json;
use crate::{
auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie},
error::ApiError,
service::{
ChangePasswordPayload, LoginPayload, UpdateCurrentWorkspacePayload, UpdateProfilePayload,
},
state::AppState,
};
pub async fn login(
State(state): State<AppState>,
jar: CookieJar,
Json(payload): Json<LoginPayload>,
) -> Result<impl IntoResponse, ApiError> {
let (session_data, session) = state.service.login(payload).await?;
let cookie_value = format!(
"{}.{}",
session_data.session_id.as_str(),
session_data.value
);
let jar = jar.add(session_cookie(state.service.auth_settings(), &cookie_value));
Ok((jar, Json(serde_json::json!(session))))
}
pub async fn logout(
State(state): State<AppState>,
jar: CookieJar,
) -> Result<impl IntoResponse, ApiError> {
if let Some((session_id, session_value)) = extract_session_token(&jar) {
state.service.logout(&session_id, &session_value).await?;
}
let jar = jar.remove(cleared_session_cookie(state.service.auth_settings()));
Ok((jar, StatusCode::NO_CONTENT))
}
pub async fn get_session(
State(state): State<AppState>,
jar: CookieJar,
) -> Result<Json<serde_json::Value>, ApiError> {
let (session_id, session_value) = extract_session_token(&jar)
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
let session = state
.service
.session_response(&session_id, &session_value)
.await?
.ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?;
Ok(Json(serde_json::json!(session)))
}
pub async fn get_profile(
Extension(session): Extension<AuthenticatedSession>,
) -> Result<Json<serde_json::Value>, ApiError> {
Ok(Json(json!({
"user": session.user,
"memberships": session.memberships,
"current_workspace_id": session.current_workspace_id
})))
}
pub async fn update_profile(
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Json(payload): Json<UpdateProfilePayload>,
) -> Result<Json<serde_json::Value>, ApiError> {
let updated = state
.service
.update_profile(
&session.user.id,
session.current_workspace_id.as_ref(),
payload,
)
.await?;
Ok(Json(json!(updated)))
}
pub async fn update_current_workspace(
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Json(payload): Json<UpdateCurrentWorkspacePayload>,
) -> Result<Json<serde_json::Value>, ApiError> {
let updated = state
.service
.set_current_workspace(
&session.session_id,
&session.user.id,
&payload.workspace_id.as_str().into(),
)
.await?;
Ok(Json(json!(updated)))
}
pub async fn change_password(
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Json(payload): Json<ChangePasswordPayload>,
) -> Result<StatusCode, ApiError> {
state
.service
.change_password(&session.user.id, payload)
.await?;
Ok(StatusCode::NO_CONTENT)
}
@@ -0,0 +1,56 @@
use axum::{
Json,
extract::{Path, State},
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{error::ApiError, service::AuthProfilePayload, state::AppState};
#[derive(Deserialize)]
pub struct WorkspacePath {
pub workspace_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceAuthProfilePath {
pub workspace_id: String,
pub auth_profile_id: String,
}
pub async fn list_auth_profiles(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.list_auth_profiles(&path.workspace_id.as_str().into())
.await?;
Ok(Json(json!({ "items": items })))
}
pub async fn create_auth_profile(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Json(payload): Json<AuthProfilePayload>,
) -> Result<Json<Value>, ApiError> {
let profile = state
.service
.create_auth_profile(&path.workspace_id.as_str().into(), payload)
.await?;
Ok(Json(json!(profile)))
}
pub async fn get_auth_profile(
Path(path): Path<WorkspaceAuthProfilePath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let profile = state
.service
.get_auth_profile(
&path.workspace_id.as_str().into(),
&path.auth_profile_id.as_str().into(),
)
.await?;
Ok(Json(json!(profile)))
}
@@ -0,0 +1,8 @@
use axum::{Json, extract::State};
use serde_json::json;
use crate::state::AppState;
pub async fn get_capabilities(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!(state.service.capability_profile().capabilities()))
}
+124
View File
@@ -0,0 +1,124 @@
use axum::{Json, extract::State};
use crank_core::{
IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, MachineAccessMode, MembershipRole,
TokenIssuerActor, TokenIssuerError, UserId, WorkspaceId,
};
use serde_json::json;
use crate::{error::ApiError, state::AppState};
pub async fn issue_agent_token(
State(state): State<AppState>,
Json(payload): Json<IssueAgentTokenRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let edition = state.service.capability_profile().capabilities().edition;
let grant_type = payload.grant_type.clone();
let response = state
.service
.token_issuer()
.issue_short_lived(payload, &community_token_issuer_actor())
.await
.map_err(|error| {
map_token_issuer_error(
error,
edition,
MachineAccessMode::ShortLivedToken,
json!({
"grant_type": grant_type,
"upgrade_required": true,
}),
)
})?;
Ok(Json(serde_json::json!(response)))
}
pub async fn issue_one_time_agent_token(
State(state): State<AppState>,
Json(payload): Json<IssueOneTimeAgentTokenRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let edition = state.service.capability_profile().capabilities().edition;
let operation_id = payload.operation_id.as_str().to_owned();
let response = state
.service
.token_issuer()
.issue_one_time(payload, &community_token_issuer_actor())
.await
.map_err(|error| {
map_token_issuer_error(
error,
edition,
MachineAccessMode::OneTimeToken,
json!({
"operation_id": operation_id,
"upgrade_required": true,
}),
)
})?;
Ok(Json(serde_json::json!(response)))
}
fn community_token_issuer_actor() -> TokenIssuerActor {
TokenIssuerActor {
user_id: UserId::new("user_community_public"),
workspace_id: WorkspaceId::new("ws_community_public"),
role: MembershipRole::Viewer,
}
}
fn map_token_issuer_error(
error: TokenIssuerError,
edition: crank_core::ProductEdition,
machine_access_mode: MachineAccessMode,
extra_context: serde_json::Value,
) -> ApiError {
match error {
TokenIssuerError::NotSupportedInEdition => ApiError::forbidden_with_context(
match machine_access_mode {
MachineAccessMode::ShortLivedToken => {
"short-lived machine access is not available in Community"
}
MachineAccessMode::OneTimeToken => {
"one-time machine access is not available in Community"
}
MachineAccessMode::StaticAgentKey => {
"static agent key machine access is not available for token issue"
}
},
merge_machine_auth_context(edition, machine_access_mode, extra_context),
),
TokenIssuerError::InvalidGrant(reason) => ApiError::validation_with_context(
"invalid machine token grant",
json!({ "reason": reason }),
),
TokenIssuerError::AgentKeyUnknown => ApiError::validation("agent key is unknown"),
TokenIssuerError::OperationNotStrict => ApiError::validation("operation is not strict"),
TokenIssuerError::OperationNotPublishedForAgent => {
ApiError::validation("operation is not published for agent")
}
TokenIssuerError::RegistryFailure(details) => {
ApiError::internal(format!("token issuer registry failure: {details}"))
}
TokenIssuerError::ReplayGuardFailure(details) => {
ApiError::internal(format!("token issuer replay guard failure: {details}"))
}
}
}
fn merge_machine_auth_context(
edition: crank_core::ProductEdition,
machine_access_mode: MachineAccessMode,
extra_context: serde_json::Value,
) -> serde_json::Value {
let mut context = json!({
"edition": edition,
"machine_access_mode": machine_access_mode,
});
if let (Some(base), Some(extra)) = (context.as_object_mut(), extra_context.as_object()) {
for (key, value) in extra {
base.insert(key.clone(), value.clone());
}
}
context
}
+100
View File
@@ -0,0 +1,100 @@
use axum::{
Json,
extract::{Path, Query, State},
};
use serde_json::{Value, json};
use crate::{
error::ApiError,
routes::access::WorkspacePath,
service::{LogsQuery, UsageRequestQuery},
state::AppState,
};
#[derive(serde::Deserialize)]
pub struct WorkspaceLogPath {
pub workspace_id: String,
pub log_id: String,
}
#[derive(serde::Deserialize)]
pub struct WorkspaceOperationUsagePath {
pub workspace_id: String,
pub operation_id: String,
}
#[derive(serde::Deserialize)]
pub struct WorkspaceAgentUsagePath {
pub workspace_id: String,
pub agent_id: String,
}
pub async fn list_logs(
Path(path): Path<WorkspacePath>,
Query(query): Query<LogsQuery>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.list_logs(&path.workspace_id.as_str().into(), query)
.await?;
Ok(Json(json!({ "items": items })))
}
pub async fn get_log(
Path(path): Path<WorkspaceLogPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let item = state
.service
.get_log(
&path.workspace_id.as_str().into(),
&path.log_id.as_str().into(),
)
.await?;
Ok(Json(json!(item)))
}
pub async fn get_usage(
Path(path): Path<WorkspacePath>,
Query(query): Query<UsageRequestQuery>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let usage = state
.service
.get_usage_overview(&path.workspace_id.as_str().into(), query)
.await?;
Ok(Json(json!(usage)))
}
pub async fn get_operation_usage(
Path(path): Path<WorkspaceOperationUsagePath>,
Query(query): Query<UsageRequestQuery>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let usage = state
.service
.get_operation_usage(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
query,
)
.await?;
Ok(Json(json!(usage)))
}
pub async fn get_agent_usage(
Path(path): Path<WorkspaceAgentUsagePath>,
Query(query): Query<UsageRequestQuery>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let usage = state
.service
.get_agent_usage(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
query,
)
.await?;
Ok(Json(json!(usage)))
}
+283
View File
@@ -0,0 +1,283 @@
use axum::{
Json,
extract::{Extension, Path, Query, State},
http::{HeaderMap, StatusCode, header},
response::IntoResponse,
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
error::ApiError,
request_context::RequestContext,
service::{
ExportQuery, GenerateDraftPayload, ImportQuery, NewVersionPayload, OperationPayload,
PublishPayload, TestRunPayload, UpdateOperationPayload,
},
state::AppState,
};
#[derive(Deserialize)]
pub struct WorkspacePath {
pub workspace_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceOperationPath {
pub workspace_id: String,
pub operation_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceOperationVersionPath {
pub workspace_id: String,
pub operation_id: String,
pub version: u32,
}
pub async fn list_operations(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.list_operations(&path.workspace_id.as_str().into())
.await?;
let total = items.len();
Ok(Json(json!({
"items": items,
"page": 1,
"page_size": total,
"total": total
})))
}
pub async fn create_operation(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Json(payload): Json<OperationPayload>,
) -> Result<Json<Value>, ApiError> {
let created = state
.service
.create_operation(&path.workspace_id.as_str().into(), payload)
.await?;
Ok(Json(json!(created)))
}
pub async fn get_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let operation = state
.service
.get_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
)
.await?;
Ok(Json(json!(operation)))
}
pub async fn update_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
Json(payload): Json<UpdateOperationPayload>,
) -> Result<Json<Value>, ApiError> {
let result = state
.service
.update_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(result)))
}
pub async fn delete_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let result = state
.service
.delete_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
)
.await?;
Ok(Json(json!(result)))
}
pub async fn get_operation_version(
Path(path): Path<WorkspaceOperationVersionPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let version = state
.service
.get_operation_version(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
path.version,
)
.await?;
Ok(Json(json!(version)))
}
pub async fn create_version(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
Json(payload): Json<NewVersionPayload>,
) -> Result<Json<Value>, ApiError> {
let created = state
.service
.create_version(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(created)))
}
pub async fn publish_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
Json(payload): Json<PublishPayload>,
) -> Result<Json<Value>, ApiError> {
let published = state
.service
.publish_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload.version,
)
.await?;
Ok(Json(json!(published)))
}
pub async fn archive_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let archived = state
.service
.archive_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
)
.await?;
Ok(Json(json!(archived)))
}
pub async fn run_test(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<TestRunPayload>,
) -> Result<Json<Value>, ApiError> {
let result = state
.service
.run_test(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload,
&request_context.request_id,
)
.await?;
Ok(Json(json!(result)))
}
pub async fn upload_input_json(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
Json(payload): Json<Value>,
) -> Result<Json<Value>, ApiError> {
let sample = state
.service
.save_json_sample(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
crank_registry::SampleKind::InputJson,
&payload,
)
.await?;
Ok(Json(json!({
"sample_id": sample.id.as_str(),
"sample_kind": sample.sample_kind,
"version": sample.version
})))
}
pub async fn upload_output_json(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
Json(payload): Json<Value>,
) -> Result<Json<Value>, ApiError> {
let sample = state
.service
.save_json_sample(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
crank_registry::SampleKind::OutputJson,
&payload,
)
.await?;
Ok(Json(json!({
"sample_id": sample.id.as_str(),
"sample_kind": sample.sample_kind,
"version": sample.version
})))
}
pub async fn generate_draft(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
Json(payload): Json<GenerateDraftPayload>,
) -> Result<Json<Value>, ApiError> {
let draft = state
.service
.generate_draft(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(draft)))
}
pub async fn export_operation(
Path(path): Path<WorkspaceOperationPath>,
Query(query): Query<ExportQuery>,
State(state): State<AppState>,
) -> Result<impl IntoResponse, ApiError> {
let yaml = state
.service
.export_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
query,
)
.await?;
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("application/yaml"),
);
Ok((StatusCode::OK, headers, yaml))
}
pub async fn import_operation(
Path(path): Path<WorkspacePath>,
Query(query): Query<ImportQuery>,
State(state): State<AppState>,
body: String,
) -> Result<Json<Value>, ApiError> {
let imported = state
.service
.import_operation(&path.workspace_id.as_str().into(), query, &body)
.await?;
Ok(Json(json!(imported)))
}
+98
View File
@@ -0,0 +1,98 @@
use axum::{
Extension, Json,
extract::{Path, State},
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
auth::AuthenticatedSession,
error::ApiError,
service::{RotateSecretPayload, SecretPayload},
state::AppState,
};
#[derive(Deserialize)]
pub struct WorkspacePath {
pub workspace_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceSecretPath {
pub workspace_id: String,
pub secret_id: String,
}
pub async fn list_secrets(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.list_secrets(&path.workspace_id.as_str().into())
.await?;
Ok(Json(json!({ "items": items })))
}
pub async fn create_secret(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Json(payload): Json<SecretPayload>,
) -> Result<Json<Value>, ApiError> {
let secret = state
.service
.create_secret(
&path.workspace_id.as_str().into(),
Some(&session.user.id),
payload,
)
.await?;
Ok(Json(json!(secret)))
}
pub async fn get_secret(
Path(path): Path<WorkspaceSecretPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let secret = state
.service
.get_secret(
&path.workspace_id.as_str().into(),
&path.secret_id.as_str().into(),
)
.await?;
Ok(Json(json!(secret)))
}
pub async fn rotate_secret(
Path(path): Path<WorkspaceSecretPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Json(payload): Json<RotateSecretPayload>,
) -> Result<Json<Value>, ApiError> {
let secret = state
.service
.rotate_secret(
&path.workspace_id.as_str().into(),
&path.secret_id.as_str().into(),
Some(&session.user.id),
payload,
)
.await?;
Ok(Json(json!(secret)))
}
pub async fn delete_secret(
Path(path): Path<WorkspaceSecretPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
state
.service
.delete_secret(
&path.workspace_id.as_str().into(),
&path.secret_id.as_str().into(),
)
.await?;
Ok(Json(json!({ "ok": true })))
}
+16
View File
@@ -0,0 +1,16 @@
use axum::{
Json,
extract::{Path, State},
};
use serde_json::{Value, json};
use crate::{error::ApiError, routes::access::WorkspacePath, state::AppState};
pub async fn list_protocol_capabilities(
Path(_path): Path<WorkspacePath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
Ok(Json(json!({
"items": state.service.list_protocol_capabilities().await
})))
}
+58
View File
@@ -0,0 +1,58 @@
use axum::{
Extension, Json,
extract::{Path, State},
};
use serde_json::{Value, json};
use crate::{
auth::AuthenticatedSession,
error::ApiError,
service::{UpdateWorkspacePayload, WorkspacePayload},
state::AppState,
};
pub async fn list_workspaces(
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.list_workspaces_for_user(&session.user.id)
.await?;
Ok(Json(json!({ "items": items })))
}
pub async fn create_workspace(
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Json(payload): Json<WorkspacePayload>,
) -> Result<Json<Value>, ApiError> {
let workspace = state
.service
.create_workspace(&session.user.id, payload)
.await?;
Ok(Json(json!(workspace)))
}
pub async fn get_workspace(
Path(workspace_id): Path<String>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let workspace = state
.service
.get_workspace(&workspace_id.as_str().into())
.await?;
Ok(Json(json!(workspace)))
}
pub async fn update_workspace(
Path(workspace_id): Path<String>,
State(state): State<AppState>,
Json(payload): Json<UpdateWorkspacePayload>,
) -> Result<Json<Value>, ApiError> {
let workspace = state
.service
.update_workspace(&workspace_id.as_str().into(), payload)
.await?;
Ok(Json(json!(workspace)))
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
use crate::service::AdminService;
use crank_runtime::RequestRateLimiter;
#[derive(Clone)]
pub struct AppState {
pub service: AdminService,
pub api_rate_limiter: RequestRateLimiter,
}
+84
View File
@@ -0,0 +1,84 @@
use std::path::{Path, PathBuf};
use crank_core::{OperationId, SampleId};
use crank_registry::SampleKind;
use serde_json::Value;
use thiserror::Error;
use tokio::fs;
#[derive(Clone)]
pub struct LocalArtifactStorage {
root: PathBuf,
}
#[derive(Debug, Error)]
pub enum StorageError {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Serialization(#[from] serde_json::Error),
#[error("invalid storage reference: {details}")]
InvalidStorageRef { details: String },
}
impl LocalArtifactStorage {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
pub async fn write_json_sample(
&self,
operation_id: &OperationId,
version: u32,
sample_kind: SampleKind,
sample_id: &SampleId,
payload: &Value,
) -> Result<String, StorageError> {
let file_name = match sample_kind {
SampleKind::InputJson => "input.json",
SampleKind::OutputJson => "output.json",
SampleKind::YamlImportSource => "source.json",
};
let path = self
.root
.join("samples")
.join(operation_id.as_str())
.join(format!("v{version}"))
.join(format!("{}_{}", sample_id.as_str(), file_name));
write_json_file(&path, payload).await?;
Ok(to_storage_ref(&path))
}
pub async fn read_json(&self, storage_ref: &str) -> Result<Value, StorageError> {
let path = from_storage_ref(storage_ref)?;
let bytes = fs::read(path).await?;
Ok(serde_json::from_slice(&bytes)?)
}
}
async fn write_json_file(path: &Path, payload: &Value) -> Result<(), StorageError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let bytes = serde_json::to_vec_pretty(payload)?;
fs::write(path, bytes).await?;
Ok(())
}
fn to_storage_ref(path: &Path) -> String {
format!("file://{}", path.display())
}
fn from_storage_ref(storage_ref: &str) -> Result<PathBuf, StorageError> {
let Some(path) = storage_ref.strip_prefix("file://") else {
return Err(StorageError::InvalidStorageRef {
details: storage_ref.to_owned(),
});
};
Ok(PathBuf::from(path))
}