diff --git a/engine/app/controllers/coplan/api/v1/base_controller.rb b/engine/app/controllers/coplan/api/v1/base_controller.rb index 7b2c3c1..f2ab554 100644 --- a/engine/app/controllers/coplan/api/v1/base_controller.rb +++ b/engine/app/controllers/coplan/api/v1/base_controller.rb @@ -2,20 +2,57 @@ module CoPlan module Api module V1 class BaseController < ActionController::API - before_action :authenticate_api_token! + before_action :authenticate_api! private - def authenticate_api_token! + def authenticate_api! token = request.headers["Authorization"]&.delete_prefix("Bearer ") - @api_token = CoPlan::ApiToken.authenticate(token) - unless @api_token - render json: { error: "Invalid or expired API token" }, status: :unauthorized + if token.present? + authenticate_via_token!(token) + return if @api_token + end + + if CoPlan.configuration.api_authenticate + attrs = CoPlan.configuration.api_authenticate.call(request) + if attrs && attrs[:external_id].present? + provision_user_from_hook!(attrs) + return + end + end + + render json: { error: "Unauthorized" }, status: :unauthorized + end + + def provision_user_from_hook!(attrs) + external_id = attrs[:external_id].to_s + @current_api_user = CoPlan::User.find_or_initialize_by(external_id: external_id) + @current_api_user.assign_attributes(attrs.slice(:name, :admin, :metadata).compact) + if @current_api_user.new_record? || @current_api_user.changed? + @current_api_user.save! end + rescue ActiveRecord::RecordNotUnique + @current_api_user = CoPlan::User.find_by!(external_id: external_id) + end + + def authenticate_via_token!(token) + @api_token = CoPlan::ApiToken.authenticate(token) end def current_user - @api_token&.user + @current_api_user || @api_token&.user + end + + # Unique identifier for the API caller — used as actor_id, holder_id, author_id. + # With token auth this is the token's ID; with hook auth it's the user's ID. + def api_actor_id + @api_token&.id || @current_api_user&.id + end + + # The type of actor making the API call. + # Token auth → "local_agent"; hook auth → "human". + def api_author_type + @api_token ? ApiToken::HOLDER_TYPE : "human" end def set_plan diff --git a/engine/app/controllers/coplan/api/v1/comments_controller.rb b/engine/app/controllers/coplan/api/v1/comments_controller.rb index abc4398..23b01a7 100644 --- a/engine/app/controllers/coplan/api/v1/comments_controller.rb +++ b/engine/app/controllers/coplan/api/v1/comments_controller.rb @@ -18,8 +18,8 @@ def create thread.save! comment = thread.comments.create!( - author_type: ApiToken::HOLDER_TYPE, - author_id: @api_token.id, + author_type: api_author_type, + author_id: api_actor_id, body_markdown: params[:body_markdown], agent_name: params[:agent_name] ) @@ -83,8 +83,8 @@ def reply end comment = thread.comments.create!( - author_type: ApiToken::HOLDER_TYPE, - author_id: @api_token.id, + author_type: api_author_type, + author_id: api_actor_id, body_markdown: params[:body_markdown], agent_name: params[:agent_name] ) diff --git a/engine/app/controllers/coplan/api/v1/leases_controller.rb b/engine/app/controllers/coplan/api/v1/leases_controller.rb index e62a9b2..7a026da 100644 --- a/engine/app/controllers/coplan/api/v1/leases_controller.rb +++ b/engine/app/controllers/coplan/api/v1/leases_controller.rb @@ -11,7 +11,7 @@ def create lease = EditLease.acquire!( plan: @plan, holder_type: ApiToken::HOLDER_TYPE, - holder_id: @api_token.id, + holder_id: api_actor_id, lease_token: lease_token ) diff --git a/engine/app/controllers/coplan/api/v1/operations_controller.rb b/engine/app/controllers/coplan/api/v1/operations_controller.rb index c8606f9..b6b37a4 100644 --- a/engine/app/controllers/coplan/api/v1/operations_controller.rb +++ b/engine/app/controllers/coplan/api/v1/operations_controller.rb @@ -33,7 +33,7 @@ def create private def apply_with_session(operations, base_revision) - session = @plan.edit_sessions.find_by(id: params[:session_id], actor_id: @api_token.id) + session = @plan.edit_sessions.find_by(id: params[:session_id], actor_id: api_actor_id) unless session&.active? render json: { error: "Edit session not found, expired, or not open" }, status: :not_found return @@ -210,8 +210,8 @@ def commit_version(current_content, result) plan: @plan, revision: new_revision, content_markdown: result[:content], - actor_type: ApiToken::HOLDER_TYPE, - actor_id: @api_token.id, + actor_type: api_author_type, + actor_id: api_actor_id, change_summary: params[:change_summary], diff_unified: diff.presence, operations_json: result[:applied], diff --git a/engine/app/controllers/coplan/api/v1/sessions_controller.rb b/engine/app/controllers/coplan/api/v1/sessions_controller.rb index cf6d7c9..37bba31 100644 --- a/engine/app/controllers/coplan/api/v1/sessions_controller.rb +++ b/engine/app/controllers/coplan/api/v1/sessions_controller.rb @@ -19,7 +19,7 @@ def create session = EditSession.create!( plan: @plan, actor_type: actor_type, - actor_id: @api_token.id, + actor_id: api_actor_id, base_revision: @plan.current_revision, expires_at: ttl.from_now ) @@ -69,7 +69,7 @@ def commit private def set_session - @session = @plan.edit_sessions.find_by(id: params[:id], actor_id: @api_token.id) + @session = @plan.edit_sessions.find_by(id: params[:id], actor_id: api_actor_id) unless @session render json: { error: "Edit session not found" }, status: :not_found end diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb index 0600280..97bcfcc 100644 --- a/engine/lib/coplan/configuration.rb +++ b/engine/lib/coplan/configuration.rb @@ -1,6 +1,6 @@ module CoPlan class Configuration - attr_accessor :authenticate, :sign_in_path + attr_accessor :authenticate, :api_authenticate, :sign_in_path attr_accessor :ai_base_url, :ai_api_key, :ai_model attr_accessor :error_reporter attr_accessor :notification_handler