summaryrefslogtreecommitdiff
path: root/makima/src/server
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-03 23:49:08 +0000
committersoryu <soryu@soryu.co>2026-02-03 23:49:19 +0000
commitc732dd048128808cd9f67f6e1176a5b565df5678 (patch)
tree6ebf359c9c3f2d8aca264c53da6367b7f0af5fc8 /makima/src/server
parent9ebc9724afcc0482a8e7cd2369c06208fedbcbd1 (diff)
downloadsoryu-c732dd048128808cd9f67f6e1176a5b565df5678.tar.gz
soryu-c732dd048128808cd9f67f6e1176a5b565df5678.zip
Allow chain creation via web interface
Diffstat (limited to 'makima/src/server')
-rw-r--r--makima/src/server/handlers/chains.rs648
-rw-r--r--makima/src/server/mod.rs18
2 files changed, 663 insertions, 3 deletions
diff --git a/makima/src/server/handlers/chains.rs b/makima/src/server/handlers/chains.rs
index 136a868..5d26e6a 100644
--- a/makima/src/server/handlers/chains.rs
+++ b/makima/src/server/handlers/chains.rs
@@ -13,8 +13,10 @@ use utoipa::ToSchema;
use uuid::Uuid;
use crate::db::models::{
- ChainContractDetail, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary,
- ChainWithContracts, CreateChainRequest, UpdateChainRequest,
+ AddContractDefinitionRequest, ChainContractDefinition, ChainContractDetail,
+ ChainDefinitionGraphResponse, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary,
+ ChainWithContracts, CreateChainRequest, StartChainResponse, UpdateChainRequest,
+ UpdateContractDefinitionRequest,
};
use crate::db::repository::{self, RepositoryError};
use crate::server::auth::Authenticated;
@@ -607,3 +609,645 @@ pub async fn get_chain_editor(
}
}
}
+
+// =============================================================================
+// Contract Definition Handlers
+// =============================================================================
+
+/// List contract definitions for a chain.
+///
+/// GET /api/v1/chains/{id}/definitions
+#[utoipa::path(
+ get,
+ path = "/api/v1/chains/{id}/definitions",
+ params(
+ ("id" = Uuid, Path, description = "Chain ID")
+ ),
+ responses(
+ (status = 200, description = "List of contract definitions", body = Vec<ChainContractDefinition>),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Chain not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn list_chain_definitions(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(chain_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify ownership
+ match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify chain ownership: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::list_chain_contract_definitions(pool, chain_id).await {
+ Ok(definitions) => Json(definitions).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to list chain definitions: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Create a contract definition for a chain.
+///
+/// POST /api/v1/chains/{id}/definitions
+#[utoipa::path(
+ post,
+ path = "/api/v1/chains/{id}/definitions",
+ params(
+ ("id" = Uuid, Path, description = "Chain ID")
+ ),
+ request_body = AddContractDefinitionRequest,
+ responses(
+ (status = 201, description = "Contract definition created", body = ChainContractDefinition),
+ (status = 400, description = "Invalid request", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Chain not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn create_chain_definition(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(chain_id): Path<Uuid>,
+ Json(req): Json<AddContractDefinitionRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Validate the request
+ if req.name.trim().is_empty() {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("VALIDATION_ERROR", "Definition name cannot be empty")),
+ )
+ .into_response();
+ }
+
+ // Verify ownership
+ match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify chain ownership: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::create_chain_contract_definition(pool, chain_id, req).await {
+ Ok(definition) => (StatusCode::CREATED, Json(definition)).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to create chain definition: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Update a contract definition.
+///
+/// PUT /api/v1/chains/{chain_id}/definitions/{definition_id}
+#[utoipa::path(
+ put,
+ path = "/api/v1/chains/{chain_id}/definitions/{definition_id}",
+ params(
+ ("chain_id" = Uuid, Path, description = "Chain ID"),
+ ("definition_id" = Uuid, Path, description = "Definition ID")
+ ),
+ request_body = UpdateContractDefinitionRequest,
+ responses(
+ (status = 200, description = "Contract definition updated", body = ChainContractDefinition),
+ (status = 400, description = "Invalid request", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Chain or definition not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn update_chain_definition(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path((chain_id, definition_id)): Path<(Uuid, Uuid)>,
+ Json(req): Json<UpdateContractDefinitionRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify ownership
+ match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify chain ownership: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ // Verify definition belongs to this chain
+ match repository::get_chain_contract_definition(pool, definition_id).await {
+ Ok(Some(def)) if def.chain_id == chain_id => {}
+ Ok(Some(_)) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Definition not found in this chain")),
+ )
+ .into_response();
+ }
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Definition not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get chain definition: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::update_chain_contract_definition(pool, definition_id, req).await {
+ Ok(definition) => Json(definition).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to update chain definition: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Delete a contract definition.
+///
+/// DELETE /api/v1/chains/{chain_id}/definitions/{definition_id}
+#[utoipa::path(
+ delete,
+ path = "/api/v1/chains/{chain_id}/definitions/{definition_id}",
+ params(
+ ("chain_id" = Uuid, Path, description = "Chain ID"),
+ ("definition_id" = Uuid, Path, description = "Definition ID")
+ ),
+ responses(
+ (status = 200, description = "Contract definition deleted"),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Chain or definition not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn delete_chain_definition(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path((chain_id, definition_id)): Path<(Uuid, Uuid)>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify ownership
+ match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify chain ownership: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ // Verify definition belongs to this chain before deleting
+ match repository::get_chain_contract_definition(pool, definition_id).await {
+ Ok(Some(def)) if def.chain_id == chain_id => {}
+ Ok(Some(_)) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Definition not found in this chain")),
+ )
+ .into_response();
+ }
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Definition not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get chain definition: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::delete_chain_contract_definition(pool, definition_id).await {
+ Ok(true) => Json(serde_json::json!({"deleted": true})).into_response(),
+ Ok(false) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Definition not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to delete chain definition: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get definition graph for a chain (shows definitions + instantiation status).
+///
+/// GET /api/v1/chains/{id}/definitions/graph
+#[utoipa::path(
+ get,
+ path = "/api/v1/chains/{id}/definitions/graph",
+ params(
+ ("id" = Uuid, Path, description = "Chain ID")
+ ),
+ responses(
+ (status = 200, description = "Definition graph structure", body = ChainDefinitionGraphResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Chain not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn get_chain_definition_graph(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(chain_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify ownership first
+ match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify chain ownership: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::get_chain_definition_graph(pool, chain_id).await {
+ Ok(Some(graph)) => Json(graph).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get chain definition graph: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+// =============================================================================
+// Chain Control Handlers
+// =============================================================================
+
+/// Start a chain (spawns supervisor and creates root contracts).
+///
+/// POST /api/v1/chains/{id}/start
+#[utoipa::path(
+ post,
+ path = "/api/v1/chains/{id}/start",
+ params(
+ ("id" = Uuid, Path, description = "Chain ID")
+ ),
+ responses(
+ (status = 200, description = "Chain started", body = StartChainResponse),
+ (status = 400, description = "Chain cannot be started", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Chain not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn start_chain(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(chain_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify ownership and get chain
+ let chain = match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await {
+ Ok(Some(c)) => c,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get chain: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Check if chain can be started
+ if chain.status == "active" {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("ALREADY_ACTIVE", "Chain is already active")),
+ )
+ .into_response();
+ }
+ if chain.status == "completed" {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("ALREADY_COMPLETED", "Chain is already completed")),
+ )
+ .into_response();
+ }
+
+ // Get definitions to check if there are any
+ let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await {
+ Ok(d) => d,
+ Err(e) => {
+ tracing::error!("Failed to list chain definitions: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ if definitions.is_empty() {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("NO_DEFINITIONS", "Chain has no contract definitions")),
+ )
+ .into_response();
+ }
+
+ // TODO: Implement chain supervisor spawning
+ // For now, just update the chain status to active
+ match repository::update_chain_status(pool, chain_id, "active").await {
+ Ok(_) => {}
+ Err(e) => {
+ tracing::error!("Failed to update chain status: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ // Return response indicating chain has started
+ // supervisor_task_id is None until we implement the supervisor daemon
+ Json(StartChainResponse {
+ chain_id,
+ supervisor_task_id: None,
+ contracts_created: vec![],
+ status: "started".to_string(),
+ })
+ .into_response()
+}
+
+/// Stop a chain (kills supervisor, marks as archived).
+///
+/// POST /api/v1/chains/{id}/stop
+#[utoipa::path(
+ post,
+ path = "/api/v1/chains/{id}/stop",
+ params(
+ ("id" = Uuid, Path, description = "Chain ID")
+ ),
+ responses(
+ (status = 200, description = "Chain stopped"),
+ (status = 400, description = "Chain cannot be stopped", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Chain not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError)
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Chains"
+)]
+pub async fn stop_chain(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(chain_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify ownership and get chain
+ let chain = match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await {
+ Ok(Some(c)) => c,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Chain not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get chain: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Check if chain can be stopped
+ if chain.status != "active" {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "NOT_ACTIVE",
+ format!("Chain is not active (status: {})", chain.status),
+ )),
+ )
+ .into_response();
+ }
+
+ // TODO: Kill the supervisor task if running
+ // Clear supervisor task ID and set status to archived
+ match repository::set_chain_supervisor_task(pool, chain_id, None).await {
+ Ok(_) => {}
+ Err(e) => {
+ tracing::error!("Failed to clear chain supervisor: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::update_chain_status(pool, chain_id, "archived").await {
+ Ok(_) => Json(serde_json::json!({"stopped": true, "status": "archived"})).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to update chain status: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index 553797f..5dde099 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -9,7 +9,7 @@ pub mod state;
use axum::{
http::StatusCode,
response::IntoResponse,
- routing::{get, post},
+ routing::{get, post, put},
Json, Router,
};
use serde::Serialize;
@@ -229,6 +229,22 @@ pub fn make_router(state: SharedState) -> Router {
.route("/chains/{id}/graph", get(chains::get_chain_graph))
.route("/chains/{id}/events", get(chains::get_chain_events))
.route("/chains/{id}/editor", get(chains::get_chain_editor))
+ // Chain contract definitions
+ .route(
+ "/chains/{id}/definitions",
+ get(chains::list_chain_definitions).post(chains::create_chain_definition),
+ )
+ .route(
+ "/chains/{chain_id}/definitions/{definition_id}",
+ put(chains::update_chain_definition).delete(chains::delete_chain_definition),
+ )
+ .route(
+ "/chains/{id}/definitions/graph",
+ get(chains::get_chain_definition_graph),
+ )
+ // Chain control
+ .route("/chains/{id}/start", post(chains::start_chain))
+ .route("/chains/{id}/stop", post(chains::stop_chain))
// Contract type templates (built-in only)
.route("/contract-types", get(templates::list_contract_types))
// Settings endpoints