yorishiro-server 0.12.1

HTTP server (REST + MCP) for Yorishiro, an MCP-native knowledge store
mod entities;
mod export;
pub(crate) mod health;
mod identity;
mod members;
mod relations;
mod schemas;
mod search;
mod setup;
mod template_library;
pub(crate) mod whoami;
mod workspaces;

use axum::Router;
use axum::routing::{get, post};
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
use utoipa::{Modify, OpenApi};
use uuid::Uuid;
use yorishiro_core::YorishiroError;
use yorishiro_core::repositories::tenancy::{self, MembershipRole};

use crate::state::AppState;

/// Parses a JSON-object query parameter (e.g. `?filter={"status":"active"}`) shared by the
/// `entities` and `search` list endpoints. `None`/empty input means "no filter".
pub(crate) fn parse_filter_param(
    raw: Option<String>,
) -> Result<Option<serde_json::Value>, YorishiroError> {
    let Some(raw) = raw.filter(|s| !s.is_empty()) else {
        return Ok(None);
    };
    serde_json::from_str(&raw).map_err(|err| YorishiroError::ValidationFailed {
        message: "filter is not valid JSON".into(),
        details: vec![],
        hint: format!("filter must be a JSON object, e.g. {{\"status\":\"active\"}}: {err}"),
    })
}

/// Shared by `members` and `workspaces`: both are tenant-wide concerns, independent of (and
/// stricter than) the presented API key's own scope -- a Member-role key can carry `write`
/// scope for content operations while still having no business adding members or managing
/// workspaces. This mirrors `yorishiro-hosted`'s `authenticate_tenant_admin`.
pub(crate) async fn require_tenant_admin(
    state: &AppState,
    tenant_id: Uuid,
    user_id: Option<Uuid>,
) -> Result<(), YorishiroError> {
    let user_id = user_id.ok_or(YorishiroError::Unauthenticated)?;
    tenancy::get_membership_role(&state.identity_pool, tenant_id, user_id)
        .await?
        .filter(|role| matches!(role, MembershipRole::Owner | MembershipRole::Admin))
        .ok_or_else(|| YorishiroError::ScopeInsufficient {
            message: "this operation is restricted to tenant owners/admins".into(),
            hint: "ask a tenant owner to grant you the admin role".into(),
        })?;
    Ok(())
}

/// Registers a single scheme named `bearer_auth` for sending the API key as a
/// Bearer token. Individual `#[utoipa::path]` items don't carry `security(...)`;
/// this registration plus `ApiDoc`'s top-level `security` attribute apply it to
/// every endpoint at once.
struct SecurityAddon;

impl Modify for SecurityAddon {
    fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
        let components = openapi.components.get_or_insert_with(Default::default);
        components.add_security_scheme(
            "bearer_auth",
            SecurityScheme::Http(
                HttpBuilder::new()
                    .scheme(HttpAuthScheme::Bearer)
                    .bearer_format("yorishiro-api-key")
                    .build(),
            ),
        );
    }
}

#[derive(OpenApi)]
#[openapi(
    paths(
        identity::signup,
        identity::login,
        setup::status,
        setup::setup,
        members::list_members,
        members::add_member,
        entities::create_entity,
        entities::get_entity,
        entities::update_entity,
        entities::delete_entity,
        entities::list_entities,
        entities::get_entity_context,
        relations::create_relation,
        relations::get_relation,
        relations::delete_relation,
        relations::list_relations,
        schemas::list_schemas,
        schemas::get_active_schema,
        schemas::get_schema_by_id,
        schemas::create_schema,
        schemas::get_entity_type_json_schema,
        schemas::list_templates,
        search::search_entities,
        export::export_jsonl,
        workspaces::list_workspaces,
        workspaces::create_workspace,
        workspaces::get_workspace,
        workspaces::delete_workspace,
        template_library::list_templates,
        template_library::get_template,
        template_library::create_template,
        template_library::update_template,
        template_library::delete_template,
        template_library::fork_template,
    ),
    components(schemas(
        identity::SignupRequest,
        identity::SignupResponse,
        identity::WorkspaceSummary,
        identity::LoginRequest,
        identity::LoginResponse,
        setup::SetupStatusResponse,
        setup::SetupRequest,
        setup::SetupResponse,
        members::AddMemberRequest,
        yorishiro_core::repositories::tenancy::MembershipRole,
        yorishiro_core::repositories::tenancy::MembershipRecord,
        yorishiro_core::repositories::tenancy::WorkspaceRecord,
        yorishiro_core::services::auth::ApiKeyScope,
        entities::CreateEntityRequest,
        entities::UpdateEntityRequest,
        relations::CreateRelationRequest,
        schemas::CreateSchemaResponse,
        schemas::CreateSchemaRequest,
        workspaces::CreateWorkspaceRequest,
        workspaces::WorkspaceDetail,
        yorishiro_core::repositories::tenancy::TemplateRecord,
        template_library::CreateTemplateRequest,
        template_library::UpdateTemplateRequest,
        template_library::ForkTemplateRequest,
    )),
    modifiers(&SecurityAddon),
    security(("bearer_auth" = [])),
    tags(
        (name = "auth", description = "Signup and login (no bearer token required)"),
        (name = "members", description = "Tenant member management (owner/admin only)"),
        (name = "workspaces", description = "Workspace management (listing is open to any tenant member; create/delete are owner/admin only)"),
        (name = "entities", description = "Entity operations"),
        (name = "relations", description = "Relation operations"),
        (name = "schemas", description = "Meta-schema operations"),
        (name = "search", description = "Vector similarity search"),
        (name = "export", description = "Bulk data export"),
        (name = "template-library", description = "Tenant-scoped, DB-backed schema template library (create/delete are owner/admin only)"),
    ),
    info(
        title = "Yorishiro API",
        description = "REST API for a user-defined-schema, MCP-native knowledge store",
    ),
)]
pub struct ApiDoc;

/// REST API routing. Returned as `Router<AppState>` without state applied, so
/// that `main.rs` can merge in the MCP routes and SwaggerUi before calling
/// `with_state` at the end.
pub fn router() -> Router<AppState> {
    // Signup/login are the only bearer-token-free endpoints, so they're the only ones an
    // unauthenticated attacker can brute-force (invite tokens, passwords). Rate-limited by
    // client IP; see `rate_limit::RateLimiter`.
    let rate_limiter =
        std::sync::Arc::new(crate::http::middleware::rate_limit::RateLimiter::from_env());
    let auth_routes = Router::new()
        .route("/auth/signup", post(identity::signup))
        .route("/auth/login", post(identity::login))
        .route("/setup", post(setup::setup))
        .route("/setup/status", get(setup::status))
        .layer(axum::middleware::from_fn_with_state(
            rate_limiter,
            crate::http::middleware::rate_limit::enforce,
        ));

    Router::new()
        .merge(auth_routes)
        .route(
            "/api/members",
            post(members::add_member).get(members::list_members),
        )
        .route(
            "/api/workspaces",
            post(workspaces::create_workspace).get(workspaces::list_workspaces),
        )
        .route(
            "/api/workspaces/{id}",
            get(workspaces::get_workspace).delete(workspaces::delete_workspace),
        )
        .route(
            "/api/entities",
            post(entities::create_entity).get(entities::list_entities),
        )
        .route(
            "/api/entities/{id}",
            get(entities::get_entity)
                .put(entities::update_entity)
                .delete(entities::delete_entity),
        )
        .route(
            "/api/entities/{id}/context",
            get(entities::get_entity_context),
        )
        .route(
            "/api/relations",
            post(relations::create_relation).get(relations::list_relations),
        )
        .route(
            "/api/relations/{id}",
            get(relations::get_relation).delete(relations::delete_relation),
        )
        .route(
            "/api/schemas",
            post(schemas::create_schema).get(schemas::list_schemas),
        )
        .route(
            "/api/schemas/active/{name}",
            get(schemas::get_active_schema),
        )
        .route(
            "/api/schemas/active/{name}/entity-types/{entity_type}/json-schema",
            get(schemas::get_entity_type_json_schema),
        )
        .route("/api/schemas/{schema_id}", get(schemas::get_schema_by_id))
        .route("/api/templates", get(schemas::list_templates))
        .route(
            "/api/template-library",
            post(template_library::create_template).get(template_library::list_templates),
        )
        .route(
            "/api/template-library/{id}",
            get(template_library::get_template)
                .put(template_library::update_template)
                .delete(template_library::delete_template),
        )
        .route(
            "/api/template-library/{id}/fork",
            post(template_library::fork_template),
        )
        .route("/api/search", get(search::search_entities))
        .route("/api/export.jsonl", get(export::export_jsonl))
}