From 4028b12aaec2c48408eb38dc2dde4313381d09d0 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Thu, 26 Feb 2026 14:48:31 -0600 Subject: [PATCH 1/3] Replace CoPlan::UserModel concern with engine-owned CoPlan::User model The engine now owns its own User model (coplan_users table) instead of requiring host apps to provide one and include CoPlan::UserModel. Engine changes: - Add CoPlan::User model with external_id, name, admin, metadata - Add config.authenticate callback (receives request, returns user attrs) - ApplicationController handles auth internally via find-or-create flow - All belongs_to associations reference CoPlan::User directly - Remove CoPlan::UserModel concern, config.user_class, CoPlan.user_class_name - Replace can_admin_coplan? with CoPlan::User#admin - CommentsHelper uses CoPlan::User and :api_tokens association - TokensController uses current_user.api_tokens (no coplan_ prefix) - Layout conditionally renders sign-out link Host app changes: - Remove include CoPlan::UserModel from User model - Replace config.user_class with config.authenticate callback - SlackNotificationJob bridges to host User via external_id for email - Migration creates coplan_users, migrates data, updates foreign keys Specs: - Add :coplan_user factory for CoPlan::User - Update sign_in_as to bridge CoPlan::User -> host User for session auth - All CoPlan-context specs use :coplan_user instead of :user Docs: - Add docs/HOST_APP_GUIDE.md with full integration guide Amp-Thread-ID: https://ampcode.com/threads/T-019c9ba6-3a56-7499-90bf-78e84f8c18cd Co-authored-by: Amp --- app/jobs/slack_notification_job.rb | 9 +- app/models/user.rb | 6 - config/initializers/coplan.rb | 14 +- ...6200001_create_coplan_users_and_migrate.rb | 53 +++++++ docs/HOST_APP_GUIDE.md | 149 ++++++++++++++++++ .../coplan/application_controller.rb | 36 ++++- .../coplan/settings/tokens_controller.rb | 8 +- engine/app/helpers/coplan/comments_helper.rb | 8 +- engine/app/models/coplan/api_token.rb | 2 +- engine/app/models/coplan/comment_thread.rb | 4 +- engine/app/models/coplan/plan.rb | 2 +- engine/app/models/coplan/plan_collaborator.rb | 4 +- engine/app/models/coplan/user.rb | 11 ++ .../app/policies/coplan/application_policy.rb | 2 +- .../views/layouts/coplan/application.html.erb | 8 +- .../20260226200000_create_coplan_users.rb | 13 ++ engine/lib/coplan.rb | 9 -- engine/lib/coplan/configuration.rb | 4 +- engine/lib/coplan/user_model.rb | 30 ---- spec/factories/api_tokens.rb | 2 +- spec/factories/comment_threads.rb | 4 +- spec/factories/comments.rb | 2 +- spec/factories/coplan_users.rb | 12 ++ spec/factories/plans.rb | 2 +- spec/jobs/automated_review_job_spec.rb | 2 +- spec/jobs/commit_expired_session_job_spec.rb | 2 +- spec/jobs/slack_notification_job_spec.rb | 13 +- spec/models/api_token_spec.rb | 2 +- spec/models/comment_thread_anchor_spec.rb | 2 +- spec/models/comment_thread_spec.rb | 2 +- spec/models/edit_lease_spec.rb | 2 +- spec/models/edit_session_spec.rb | 2 +- spec/rails_helper.rb | 9 +- spec/requests/api/v1/comments_spec.rb | 4 +- spec/requests/api/v1/leases_spec.rb | 4 +- spec/requests/api/v1/operations_spec.rb | 2 +- spec/requests/api/v1/plans_spec.rb | 6 +- spec/requests/api/v1/sessions_spec.rb | 2 +- spec/requests/api_tokens_spec.rb | 2 +- spec/requests/automated_reviews_spec.rb | 2 +- spec/requests/comment_threads_spec.rb | 4 +- spec/requests/comments_spec.rb | 2 +- spec/requests/plans_spec.rb | 4 +- spec/services/plans/commit_session_spec.rb | 2 +- spec/services/plans/create_spec.rb | 2 +- 45 files changed, 359 insertions(+), 107 deletions(-) create mode 100644 db/migrate/20260226200001_create_coplan_users_and_migrate.rb create mode 100644 docs/HOST_APP_GUIDE.md create mode 100644 engine/app/models/coplan/user.rb create mode 100644 engine/db/migrate/20260226200000_create_coplan_users.rb delete mode 100644 engine/lib/coplan/user_model.rb create mode 100644 spec/factories/coplan_users.rb diff --git a/app/jobs/slack_notification_job.rb b/app/jobs/slack_notification_job.rb index c2ec13f..ee80719 100644 --- a/app/jobs/slack_notification_job.rb +++ b/app/jobs/slack_notification_job.rb @@ -9,14 +9,17 @@ def perform(comment_thread_id:) thread = CoPlan::CommentThread.find(comment_thread_id) plan = thread.plan - plan_author = plan.created_by_user + coplan_author = plan.created_by_user first_comment = thread.comments.order(:created_at, :id).first return unless first_comment - return if first_comment.author_type == "human" && first_comment.author_id == plan_author.id + return if first_comment.author_type == "human" && first_comment.author_id == coplan_author.id + + host_user = User.find_by(id: coplan_author.external_id) + return unless host_user text = compose_message(thread, plan) - SlackClient.send_dm(email: plan_author.email, text: text) + SlackClient.send_dm(email: host_user.email, text: text) end private diff --git a/app/models/user.rb b/app/models/user.rb index ec80c85..dc04871 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,4 @@ class User < ApplicationRecord - include CoPlan::UserModel - validates :email, presence: true, uniqueness: true validates :name, presence: true validates :role, presence: true, inclusion: { in: %w[member admin] } @@ -9,10 +7,6 @@ def admin? role == "admin" end - def can_admin_coplan? - admin? - end - def email_domain email.to_s.split("@").last&.downcase end diff --git a/config/initializers/coplan.rb b/config/initializers/coplan.rb index e1da1b4..64efe6c 100644 --- a/config/initializers/coplan.rb +++ b/config/initializers/coplan.rb @@ -1,5 +1,17 @@ CoPlan.configure do |config| - config.user_class = "User" + config.authenticate = ->(request) { + user_id = request.session[:user_id] + return nil unless user_id + + user = User.find_by(id: user_id) + return nil unless user + + { + external_id: user.id, + name: user.name, + admin: user.admin? + } + } config.ai_api_key = Rails.application.credentials.dig(:openai, :api_key) || ENV["OPENAI_API_KEY"] config.ai_model = "gpt-4o" diff --git a/db/migrate/20260226200001_create_coplan_users_and_migrate.rb b/db/migrate/20260226200001_create_coplan_users_and_migrate.rb new file mode 100644 index 0000000..38aec56 --- /dev/null +++ b/db/migrate/20260226200001_create_coplan_users_and_migrate.rb @@ -0,0 +1,53 @@ +class CreateCoplanUsersAndMigrate < ActiveRecord::Migration[8.1] + def up + create_table :coplan_users, id: { type: :string, limit: 36 } do |t| + t.string :external_id, null: false + t.string :name, null: false + t.boolean :admin, default: false, null: false + t.json :metadata, default: {} + t.timestamps + end + + add_index :coplan_users, :external_id, unique: true + + # Migrate existing users — reuse the same id so FK columns stay valid + execute <<~SQL + INSERT INTO coplan_users (id, external_id, name, admin, metadata, created_at, updated_at) + SELECT id, id, name, (role = 'admin'), '{}', created_at, updated_at + FROM users + SQL + + # Update foreign keys to point to coplan_users instead of users + remove_foreign_key :coplan_api_tokens, :users + remove_foreign_key :coplan_comment_threads, column: :created_by_user_id + remove_foreign_key :coplan_comment_threads, column: :resolved_by_user_id + remove_foreign_key :coplan_plan_collaborators, :users + remove_foreign_key :coplan_plan_collaborators, column: :added_by_user_id + remove_foreign_key :coplan_plans, column: :created_by_user_id + + add_foreign_key :coplan_api_tokens, :coplan_users, column: :user_id + add_foreign_key :coplan_comment_threads, :coplan_users, column: :created_by_user_id + add_foreign_key :coplan_comment_threads, :coplan_users, column: :resolved_by_user_id + add_foreign_key :coplan_plan_collaborators, :coplan_users, column: :user_id + add_foreign_key :coplan_plan_collaborators, :coplan_users, column: :added_by_user_id + add_foreign_key :coplan_plans, :coplan_users, column: :created_by_user_id + end + + def down + remove_foreign_key :coplan_api_tokens, :coplan_users + remove_foreign_key :coplan_comment_threads, column: :created_by_user_id + remove_foreign_key :coplan_comment_threads, column: :resolved_by_user_id + remove_foreign_key :coplan_plan_collaborators, :coplan_users + remove_foreign_key :coplan_plan_collaborators, column: :added_by_user_id + remove_foreign_key :coplan_plans, column: :created_by_user_id + + add_foreign_key :coplan_api_tokens, :users + add_foreign_key :coplan_comment_threads, :users, column: :created_by_user_id + add_foreign_key :coplan_comment_threads, :users, column: :resolved_by_user_id + add_foreign_key :coplan_plan_collaborators, :users + add_foreign_key :coplan_plan_collaborators, :users, column: :added_by_user_id + add_foreign_key :coplan_plans, :users, column: :created_by_user_id + + drop_table :coplan_users + end +end diff --git a/docs/HOST_APP_GUIDE.md b/docs/HOST_APP_GUIDE.md new file mode 100644 index 0000000..cc6df9d --- /dev/null +++ b/docs/HOST_APP_GUIDE.md @@ -0,0 +1,149 @@ +# CoPlan Host App Integration Guide + +## Overview + +CoPlan is a Rails engine that manages collaborative planning documents. It owns its own `CoPlan::User` model and handles authentication internally via a callback you configure. + +## Setup + +### 1. Mount the engine + +```ruby +# config/routes.rb +mount CoPlan::Engine, at: "/coplan" +``` + +### 2. Install migrations + +```bash +bin/rails coplan:install:migrations +bin/rails db:migrate +``` + +This creates the engine's tables (`coplan_users`, `coplan_plans`, etc.) in your database. + +### 3. Configure authentication + +Provide an `authenticate` callback that receives a Rack request and returns user identity attributes (or `nil` if unauthenticated): + +```ruby +# config/initializers/coplan.rb +CoPlan.configure do |config| + config.authenticate = ->(request) { + # Example: session-based auth + user_id = request.session[:user_id] + return nil unless user_id + + user = User.find_by(id: user_id) + return nil unless user + + { + external_id: user.id.to_s, # required — unique ID from your auth system + name: user.name, # required — display name + admin: user.admin?, # optional — can manage CoPlan settings (default: false) + metadata: {} # optional — arbitrary data (default: {}) + } + } + + # Optional: AI provider configuration + config.ai_api_key = ENV["OPENAI_API_KEY"] + config.ai_model = "gpt-4o" +end +``` + +The callback is called on every CoPlan request. The engine automatically finds or creates a `CoPlan::User` from the returned attributes, keeping the name and admin flag in sync. + +### Callback return values + +| Key | Type | Required | Description | +|---------------|---------|----------|-------------| +| `external_id` | String | Yes | Unique identifier from your auth system | +| `name` | String | Yes | Display name | +| `admin` | Boolean | No | Can manage reviewers, settings (default: `false`) | +| `metadata` | Hash | No | Arbitrary data stored as JSON (default: `{}`) | + +Return `nil` to indicate the user is not authenticated (the engine will respond with `401 Unauthorized`). + +## Authentication examples + +### Trogdor (Square internal) + +```ruby +config.authenticate = ->(request) { + trogdor = Rails::Auth.credentials(request.env)["trogdor"] + return nil unless trogdor + + { + external_id: trogdor.uid.to_s, + name: trogdor.username, + admin: trogdor.capabilities.include?("owners") + } +} +``` + +### Devise + +```ruby +config.authenticate = ->(request) { + env = request.env + warden = env["warden"] + user = warden&.user + return nil unless user + + { + external_id: user.id.to_s, + name: user.name, + admin: user.admin? + } +} +``` + +## CoPlan::User model + +The engine manages a `coplan_users` table with these columns: + +| Column | Type | Description | +|---------------|---------|-------------| +| `id` | String | UUIDv7 primary key (auto-assigned) | +| `external_id` | String | Unique ID from your auth system | +| `name` | String | Display name | +| `admin` | Boolean | Admin flag | +| `metadata` | JSON | Extensible data bag | + +`CoPlan::User` is a normal ActiveRecord model. Host apps can reference it directly: + +```ruby +class Notification < ApplicationRecord + belongs_to :user, class_name: "CoPlan::User" +end +``` + +## API tokens + +CoPlan provides a REST API for programmatic access. Users create API tokens in the Settings UI. API requests authenticate via `Authorization: Bearer ` headers — no session or callback required. + +## Layout and sign-out + +CoPlan inherits from your `::ApplicationController` for layout and middleware. The engine's nav bar will render a "Sign out" link if your app defines a `sign_out_path` route helper. + +## Configuration reference + +```ruby +CoPlan.configure do |config| + # Required + config.authenticate = ->(request) { ... } + + # AI provider (optional) + config.ai_base_url = "https://api.openai.com/v1" # default + config.ai_api_key = nil + config.ai_model = "gpt-4o" # default + + # Error reporting (optional) + config.error_reporter = ->(exception, context) { + Rails.error.report(exception, context: context) # default + } + + # Notifications (optional) + config.notification_handler = ->(event, payload) { ... } +end +``` diff --git a/engine/app/controllers/coplan/application_controller.rb b/engine/app/controllers/coplan/application_controller.rb index 9b1e023..ff4e351 100644 --- a/engine/app/controllers/coplan/application_controller.rb +++ b/engine/app/controllers/coplan/application_controller.rb @@ -11,10 +11,44 @@ def self.controller_path helper CoPlan::MarkdownHelper helper CoPlan::CommentsHelper + before_action :authenticate_coplan_user! before_action :set_coplan_current + helper_method :current_user, :signed_in? + + class NotAuthorizedError < StandardError; end + + rescue_from NotAuthorizedError do + head :not_found + end + private + def current_user + @current_coplan_user + end + + def signed_in? + current_user.present? + end + + def authenticate_coplan_user! + callback = CoPlan.configuration.authenticate + unless callback + raise "CoPlan.configure { |c| c.authenticate = ->(request) { ... } } is required" + end + + attrs = callback.call(request) + unless attrs && attrs[:external_id].present? + head :unauthorized + return + end + + @current_coplan_user = CoPlan::User.find_or_initialize_by(external_id: attrs[:external_id].to_s) + @current_coplan_user.assign_attributes(attrs.slice(:name, :admin, :metadata).compact) + @current_coplan_user.save! if @current_coplan_user.new_record? || @current_coplan_user.changed? + end + def set_coplan_current CoPlan::Current.user = current_user end @@ -23,7 +57,7 @@ def authorize!(record, action) policy_class = "CoPlan::#{record.class.name.demodulize}Policy".constantize policy = policy_class.new(current_user, record) unless policy.public_send(action) - raise ::ApplicationController::NotAuthorizedError + raise NotAuthorizedError end end end diff --git a/engine/app/controllers/coplan/settings/tokens_controller.rb b/engine/app/controllers/coplan/settings/tokens_controller.rb index 9717994..6eb8bca 100644 --- a/engine/app/controllers/coplan/settings/tokens_controller.rb +++ b/engine/app/controllers/coplan/settings/tokens_controller.rb @@ -2,22 +2,22 @@ module CoPlan module Settings class TokensController < ApplicationController def index - @api_tokens = current_user.coplan_api_tokens.order(created_at: :desc) + @api_tokens = current_user.api_tokens.order(created_at: :desc) end def create @api_token, @raw_token = ApiToken.create_with_raw_token(user: current_user, name: params[:api_token][:name]) - @api_tokens = current_user.coplan_api_tokens.order(created_at: :desc) + @api_tokens = current_user.api_tokens.order(created_at: :desc) flash.now[:notice] = "Token created. Copy it now — it won't be shown again." render :index rescue ActiveRecord::RecordInvalid => e - @api_tokens = current_user.coplan_api_tokens.order(created_at: :desc) + @api_tokens = current_user.api_tokens.order(created_at: :desc) flash.now[:alert] = e.message render :index, status: :unprocessable_entity end def destroy - token = current_user.coplan_api_tokens.find(params[:id]) + token = current_user.api_tokens.find(params[:id]) token.revoke! redirect_to settings_tokens_path, notice: "Token revoked." end diff --git a/engine/app/helpers/coplan/comments_helper.rb b/engine/app/helpers/coplan/comments_helper.rb index dee39d7..27b6445 100644 --- a/engine/app/helpers/coplan/comments_helper.rb +++ b/engine/app/helpers/coplan/comments_helper.rb @@ -3,11 +3,11 @@ module CommentsHelper def comment_author_name(comment) case comment.author_type when "human" - CoPlan.user_class.find_by(id: comment.author_id)&.name || "Unknown" + CoPlan::User.find_by(id: comment.author_id)&.name || "Unknown" when "local_agent" - user_name = CoPlan.user_class - .joins("INNER JOIN coplan_api_tokens ON coplan_api_tokens.user_id = users.id") - .where("coplan_api_tokens.id = ?", comment.author_id) + user_name = CoPlan::User + .joins(:api_tokens) + .where(coplan_api_tokens: { id: comment.author_id }) .pick(:name) || "Agent" comment.agent_name.present? ? "#{user_name} (#{comment.agent_name})" : user_name when "cloud_persona" diff --git a/engine/app/models/coplan/api_token.rb b/engine/app/models/coplan/api_token.rb index 4ca256d..67909db 100644 --- a/engine/app/models/coplan/api_token.rb +++ b/engine/app/models/coplan/api_token.rb @@ -2,7 +2,7 @@ module CoPlan class ApiToken < ApplicationRecord HOLDER_TYPE = "local_agent" - belongs_to :user, class_name: CoPlan.user_class_name + belongs_to :user, class_name: "CoPlan::User" validates :name, presence: true validates :token_digest, presence: true, uniqueness: true diff --git a/engine/app/models/coplan/comment_thread.rb b/engine/app/models/coplan/comment_thread.rb index 7bc5d1f..0c8aa1a 100644 --- a/engine/app/models/coplan/comment_thread.rb +++ b/engine/app/models/coplan/comment_thread.rb @@ -6,8 +6,8 @@ class CommentThread < ApplicationRecord belongs_to :plan belongs_to :plan_version - belongs_to :created_by_user, class_name: CoPlan.user_class_name - belongs_to :resolved_by_user, class_name: CoPlan.user_class_name, optional: true + belongs_to :created_by_user, class_name: "CoPlan::User" + belongs_to :resolved_by_user, class_name: "CoPlan::User", optional: true belongs_to :out_of_date_since_version, class_name: "PlanVersion", optional: true belongs_to :addressed_in_plan_version, class_name: "PlanVersion", optional: true has_many :comments, dependent: :destroy diff --git a/engine/app/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb index 8409e94..6a5fec2 100644 --- a/engine/app/models/coplan/plan.rb +++ b/engine/app/models/coplan/plan.rb @@ -2,7 +2,7 @@ module CoPlan class Plan < ApplicationRecord STATUSES = %w[brainstorm considering developing live abandoned].freeze - belongs_to :created_by_user, class_name: CoPlan.user_class_name + belongs_to :created_by_user, class_name: "CoPlan::User" belongs_to :current_plan_version, class_name: "PlanVersion", optional: true has_many :plan_versions, -> { order(revision: :asc) }, dependent: :destroy has_many :plan_collaborators, dependent: :destroy diff --git a/engine/app/models/coplan/plan_collaborator.rb b/engine/app/models/coplan/plan_collaborator.rb index e014f55..7aef677 100644 --- a/engine/app/models/coplan/plan_collaborator.rb +++ b/engine/app/models/coplan/plan_collaborator.rb @@ -3,8 +3,8 @@ class PlanCollaborator < ApplicationRecord ROLES = %w[author reviewer viewer].freeze belongs_to :plan - belongs_to :user, class_name: CoPlan.user_class_name - belongs_to :added_by_user, class_name: CoPlan.user_class_name, optional: true + belongs_to :user, class_name: "CoPlan::User" + belongs_to :added_by_user, class_name: "CoPlan::User", optional: true validates :role, presence: true, inclusion: { in: ROLES } validates :user_id, uniqueness: { scope: :plan_id } diff --git a/engine/app/models/coplan/user.rb b/engine/app/models/coplan/user.rb new file mode 100644 index 0000000..020474a --- /dev/null +++ b/engine/app/models/coplan/user.rb @@ -0,0 +1,11 @@ +module CoPlan + class User < ApplicationRecord + has_many :api_tokens, dependent: :destroy + has_many :plan_collaborators, dependent: :destroy + + validates :external_id, presence: true, uniqueness: true + validates :name, presence: true + + after_initialize { self.metadata ||= {} } + end +end diff --git a/engine/app/policies/coplan/application_policy.rb b/engine/app/policies/coplan/application_policy.rb index 912ba32..2241f53 100644 --- a/engine/app/policies/coplan/application_policy.rb +++ b/engine/app/policies/coplan/application_policy.rb @@ -8,7 +8,7 @@ def initialize(user, record) end def admin? - user.can_admin_coplan? + user.admin? end end end diff --git a/engine/app/views/layouts/coplan/application.html.erb b/engine/app/views/layouts/coplan/application.html.erb index 2619ac1..2106d75 100644 --- a/engine/app/views/layouts/coplan/application.html.erb +++ b/engine/app/views/layouts/coplan/application.html.erb @@ -14,15 +14,17 @@ diff --git a/engine/db/migrate/20260226200000_create_coplan_users.rb b/engine/db/migrate/20260226200000_create_coplan_users.rb new file mode 100644 index 0000000..36074b8 --- /dev/null +++ b/engine/db/migrate/20260226200000_create_coplan_users.rb @@ -0,0 +1,13 @@ +class CreateCoplanUsers < ActiveRecord::Migration[8.1] + def change + create_table :coplan_users, id: { type: :string, limit: 36 } do |t| + t.string :external_id, null: false + t.string :name, null: false + t.boolean :admin, default: false, null: false + t.json :metadata, default: {} + t.timestamps + end + + add_index :coplan_users, :external_id, unique: true + end +end diff --git a/engine/lib/coplan.rb b/engine/lib/coplan.rb index 274870b..8b5707b 100644 --- a/engine/lib/coplan.rb +++ b/engine/lib/coplan.rb @@ -1,5 +1,4 @@ require "coplan/configuration" -require "coplan/user_model" require "coplan/engine" module CoPlan @@ -11,13 +10,5 @@ def configuration def configure yield(configuration) end - - def user_class - configuration.user_class.constantize - end - - def user_class_name - configuration.user_class - end end end diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb index 66e2f7a..0666a92 100644 --- a/engine/lib/coplan/configuration.rb +++ b/engine/lib/coplan/configuration.rb @@ -1,12 +1,12 @@ module CoPlan class Configuration - attr_accessor :user_class + attr_accessor :authenticate attr_accessor :ai_base_url, :ai_api_key, :ai_model attr_accessor :error_reporter attr_accessor :notification_handler def initialize - @user_class = "User" + @authenticate = nil @ai_base_url = "https://api.openai.com/v1" @ai_api_key = nil @ai_model = "gpt-4o" diff --git a/engine/lib/coplan/user_model.rb b/engine/lib/coplan/user_model.rb deleted file mode 100644 index 9e0b963..0000000 --- a/engine/lib/coplan/user_model.rb +++ /dev/null @@ -1,30 +0,0 @@ -module CoPlan - # Concern to be included by the host app's User model. - # - # Required interface: - # #id → String (any unique string — UUID, ULID, etc.) - # #name → String (display name) - # #can_admin_coplan? → Boolean (can this user manage reviewers, prompts, etc.) - # - # The engine manages its own associations (api_tokens, plan_collaborators) - # internally — the host app does not need to declare them. - module UserModel - extend ActiveSupport::Concern - - included do - has_many :coplan_api_tokens, - class_name: "CoPlan::ApiToken", - foreign_key: :user_id, - dependent: :destroy - - has_many :coplan_plan_collaborators, - class_name: "CoPlan::PlanCollaborator", - foreign_key: :user_id, - dependent: :destroy - end - - def can_admin_coplan? - false - end - end -end diff --git a/spec/factories/api_tokens.rb b/spec/factories/api_tokens.rb index 4e85b2a..c38c10c 100644 --- a/spec/factories/api_tokens.rb +++ b/spec/factories/api_tokens.rb @@ -1,6 +1,6 @@ FactoryBot.define do factory :api_token, class: "CoPlan::ApiToken" do - user { association(:user) } + user { association(:coplan_user) } sequence(:name) { |n| "Token #{n}" } token_digest { Digest::SHA256.hexdigest(SecureRandom.hex(32)) } token_prefix { SecureRandom.hex(4) } diff --git a/spec/factories/comment_threads.rb b/spec/factories/comment_threads.rb index 8697052..34f3fd2 100644 --- a/spec/factories/comment_threads.rb +++ b/spec/factories/comment_threads.rb @@ -2,7 +2,7 @@ factory :comment_thread, class: "CoPlan::CommentThread" do plan plan_version { plan.current_plan_version } - created_by_user { association(:user) } + created_by_user { association(:coplan_user) } status { "open" } out_of_date { false } @@ -19,7 +19,7 @@ trait :resolved do status { "resolved" } - resolved_by_user { association(:user) } + resolved_by_user { association(:coplan_user) } end end end diff --git a/spec/factories/comments.rb b/spec/factories/comments.rb index dc0bd15..c3bf3f8 100644 --- a/spec/factories/comments.rb +++ b/spec/factories/comments.rb @@ -2,7 +2,7 @@ factory :comment, class: "CoPlan::Comment" do comment_thread author_type { "human" } - author_id { association(:user).id } + author_id { association(:coplan_user).id } body_markdown { "A comment body." } end end diff --git a/spec/factories/coplan_users.rb b/spec/factories/coplan_users.rb new file mode 100644 index 0000000..e4c8134 --- /dev/null +++ b/spec/factories/coplan_users.rb @@ -0,0 +1,12 @@ +FactoryBot.define do + factory :coplan_user, class: "CoPlan::User" do + sequence(:external_id) { |n| SecureRandom.uuid_v7 } + sequence(:name) { |n| "User #{n}" } + admin { false } + metadata { {} } + + trait :admin do + admin { true } + end + end +end diff --git a/spec/factories/plans.rb b/spec/factories/plans.rb index 6f1de28..c3ffb0e 100644 --- a/spec/factories/plans.rb +++ b/spec/factories/plans.rb @@ -1,6 +1,6 @@ FactoryBot.define do factory :plan, class: "CoPlan::Plan" do - created_by_user { association(:user) } + created_by_user { association(:coplan_user) } sequence(:title) { |n| "Plan #{n}" } status { "brainstorm" } tags { [] } diff --git a/spec/jobs/automated_review_job_spec.rb b/spec/jobs/automated_review_job_spec.rb index 0255321..0262382 100644 --- a/spec/jobs/automated_review_job_spec.rb +++ b/spec/jobs/automated_review_job_spec.rb @@ -60,7 +60,7 @@ end it "sets the triggered_by user as the thread creator" do - other_user = create(:user) + other_user = create(:coplan_user) described_class.perform_now(**perform_args.merge(triggered_by: other_user)) CoPlan::CommentThread.last(3).each do |thread| diff --git a/spec/jobs/commit_expired_session_job_spec.rb b/spec/jobs/commit_expired_session_job_spec.rb index ebe2bb7..9ab1c4a 100644 --- a/spec/jobs/commit_expired_session_job_spec.rb +++ b/spec/jobs/commit_expired_session_job_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe CoPlan::CommitExpiredSessionJob do - let(:user) { create(:user) } + let(:user) { create(:coplan_user) } let(:content) { "# Test Plan\n\nSome content here." } let(:plan) do plan = CoPlan::Plan.create!(title: "Test Plan", created_by_user: user) diff --git a/spec/jobs/slack_notification_job_spec.rb b/spec/jobs/slack_notification_job_spec.rb index f1beb09..52f7658 100644 --- a/spec/jobs/slack_notification_job_spec.rb +++ b/spec/jobs/slack_notification_job_spec.rb @@ -1,9 +1,12 @@ require "rails_helper" RSpec.describe SlackNotificationJob, type: :job do - let(:plan_author) { create(:user) } - let(:commenter) { create(:user) } + let(:plan_author) { create(:coplan_user) } + let(:host_author) { User.create!(id: plan_author.external_id, email: "author@example.com", name: plan_author.name, role: "member") } + let(:commenter) { create(:coplan_user) } let(:plan) { create(:plan, created_by_user: plan_author) } + + before { host_author } let(:thread_record) do create(:comment_thread, plan: plan, plan_version: plan.current_plan_version, created_by_user: commenter) @@ -25,7 +28,7 @@ described_class.perform_now(comment_thread_id: thread_record.id) expect(SlackClient).to have_received(:send_dm).with( - email: plan_author.email, + email: host_author.email, text: a_string_including("New comment on *#{plan.title}*") ) end @@ -36,7 +39,7 @@ described_class.perform_now(comment_thread_id: thread_record.id) expect(SlackClient).to have_received(:send_dm).with( - email: plan_author.email, + email: host_author.email, text: a_string_including("some highlighted text").and(a_string_including("A comment body.")) ) end @@ -45,7 +48,7 @@ described_class.perform_now(comment_thread_id: thread_record.id) expect(SlackClient).to have_received(:send_dm).with( - email: plan_author.email, + email: host_author.email, text: a_string_including("A comment body.") ) end diff --git a/spec/models/api_token_spec.rb b/spec/models/api_token_spec.rb index 4caf46a..6c9abd3 100644 --- a/spec/models/api_token_spec.rb +++ b/spec/models/api_token_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe CoPlan::ApiToken, type: :model do - let(:user) { create(:user) } + let(:user) { create(:coplan_user) } it "is valid with valid attributes" do token = create(:api_token, user: user) diff --git a/spec/models/comment_thread_anchor_spec.rb b/spec/models/comment_thread_anchor_spec.rb index d194505..68179cc 100644 --- a/spec/models/comment_thread_anchor_spec.rb +++ b/spec/models/comment_thread_anchor_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe CoPlan::CommentThread, "anchor tracking" do - let(:user) { create(:user) } + let(:user) { create(:coplan_user) } let(:content) { "# My Plan\n\nFirst section.\n\n## Goals\n\nWe should use unit tests.\n\n## Timeline\n\nQ1 2026." } let(:plan) do plan = CoPlan::Plan.create!(title: "Test", created_by_user: user) diff --git a/spec/models/comment_thread_spec.rb b/spec/models/comment_thread_spec.rb index 31eeb82..cdf46bf 100644 --- a/spec/models/comment_thread_spec.rb +++ b/spec/models/comment_thread_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe CoPlan::CommentThread, type: :model do - let(:user) { create(:user) } + let(:user) { create(:coplan_user) } let(:plan) { create(:plan, created_by_user: user) } let(:thread_record) { create(:comment_thread, plan: plan, plan_version: plan.current_plan_version, created_by_user: user) } diff --git a/spec/models/edit_lease_spec.rb b/spec/models/edit_lease_spec.rb index d53480c..75175f3 100644 --- a/spec/models/edit_lease_spec.rb +++ b/spec/models/edit_lease_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe CoPlan::EditLease, type: :model do - let(:user) { create(:user) } + let(:user) { create(:coplan_user) } let(:plan) { create(:plan, created_by_user: user) } let(:api_token) { create(:api_token, user: user) } let(:lease_token) { SecureRandom.hex(32) } diff --git a/spec/models/edit_session_spec.rb b/spec/models/edit_session_spec.rb index e04447a..f573b58 100644 --- a/spec/models/edit_session_spec.rb +++ b/spec/models/edit_session_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe CoPlan::EditSession, type: :model do - let(:user) { create(:user) } + let(:user) { create(:coplan_user) } let(:plan) { create(:plan, created_by_user: user) } describe "validations" do diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 8f7788a..01bfdd7 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -21,7 +21,12 @@ - def sign_in_as(user) - post sign_in_path, params: { email: user.email } + def sign_in_as(coplan_user) + host_user = User.find_or_create_by!(id: coplan_user.external_id) do |u| + u.email = "#{coplan_user.external_id}@test.example.com" + u.name = coplan_user.name + u.role = coplan_user.admin? ? "admin" : "member" + end + post sign_in_path, params: { email: host_user.email } end end diff --git a/spec/requests/api/v1/comments_spec.rb b/spec/requests/api/v1/comments_spec.rb index 36dfbcf..7d7b331 100644 --- a/spec/requests/api/v1/comments_spec.rb +++ b/spec/requests/api/v1/comments_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe "Api::V1::Comments", type: :request do - let(:alice) { create(:user, :admin) } + let(:alice) { create(:coplan_user, :admin) } let(:alice_token) { create(:api_token, user: alice, raw_token: "test-token-alice") } let(:headers) { { "Authorization" => "Bearer test-token-alice" } } let(:plan) { create(:plan, :considering, created_by_user: alice) } @@ -97,7 +97,7 @@ end it "returns 403 when user is not the plan author" do - bob = create(:user) + bob = create(:coplan_user) bob_token = create(:api_token, user: bob, raw_token: "test-token-bob") bob_headers = { "Authorization" => "Bearer test-token-bob" } diff --git a/spec/requests/api/v1/leases_spec.rb b/spec/requests/api/v1/leases_spec.rb index dc0b871..0cbbcdd 100644 --- a/spec/requests/api/v1/leases_spec.rb +++ b/spec/requests/api/v1/leases_spec.rb @@ -1,8 +1,8 @@ require "rails_helper" RSpec.describe "Api::V1::Leases", type: :request do - let(:alice) { create(:user, :admin) } - let(:bob) { create(:user) } + let(:alice) { create(:coplan_user, :admin) } + let(:bob) { create(:coplan_user) } let(:alice_token) { create(:api_token, user: alice, raw_token: "test-token-alice") } let(:bob_token) { create(:api_token, user: bob, raw_token: "test-token-bob") } let(:headers) { { "Authorization" => "Bearer test-token-alice" } } diff --git a/spec/requests/api/v1/operations_spec.rb b/spec/requests/api/v1/operations_spec.rb index c4cb198..1927404 100644 --- a/spec/requests/api/v1/operations_spec.rb +++ b/spec/requests/api/v1/operations_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe "Api::V1::Operations", type: :request do - let(:alice) { create(:user, :admin) } + let(:alice) { create(:coplan_user, :admin) } let(:alice_token) { create(:api_token, user: alice, raw_token: "test-token-alice") } let(:headers) { { "Authorization" => "Bearer test-token-alice" } } let(:plan) { create(:plan, :considering, created_by_user: alice) } diff --git a/spec/requests/api/v1/plans_spec.rb b/spec/requests/api/v1/plans_spec.rb index 1a4276f..0f03a51 100644 --- a/spec/requests/api/v1/plans_spec.rb +++ b/spec/requests/api/v1/plans_spec.rb @@ -1,8 +1,8 @@ require "rails_helper" RSpec.describe "Api::V1::Plans", type: :request do - let(:alice) { create(:user, :admin) } - let(:carol) { create(:user, :admin, email: "carol@other.com") } + let(:alice) { create(:coplan_user, :admin) } + let(:carol) { create(:coplan_user, :admin) } let(:alice_token) { create(:api_token, user: alice, raw_token: "test-token-alice") } let(:carol_token) { create(:api_token, user: carol, raw_token: "test-token-carol") } let(:revoked_token) { create(:api_token, :revoked, user: alice, raw_token: "test-token-revoked") } @@ -123,7 +123,7 @@ end it "returns 403 for non-author" do - bob = create(:user) + bob = create(:coplan_user) bob_token = create(:api_token, user: bob, raw_token: "test-token-bob") patch api_v1_plan_path(plan), params: { title: "Nope" }, headers: { "Authorization" => "Bearer test-token-bob" }, as: :json expect(response).to have_http_status(:forbidden) diff --git a/spec/requests/api/v1/sessions_spec.rb b/spec/requests/api/v1/sessions_spec.rb index f439a96..9fc3f0e 100644 --- a/spec/requests/api/v1/sessions_spec.rb +++ b/spec/requests/api/v1/sessions_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe "Api::V1::Sessions", type: :request do - let(:alice) { create(:user, :admin) } + let(:alice) { create(:coplan_user, :admin) } let(:alice_token) { create(:api_token, user: alice, raw_token: "test-token-alice") } let(:headers) { { "Authorization" => "Bearer test-token-alice" } } let(:plan) { create(:plan, :considering, created_by_user: alice) } diff --git a/spec/requests/api_tokens_spec.rb b/spec/requests/api_tokens_spec.rb index 06e0743..d3a77d3 100644 --- a/spec/requests/api_tokens_spec.rb +++ b/spec/requests/api_tokens_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe "Settings::Tokens", type: :request do - let(:alice) { create(:user, :admin) } + let(:alice) { create(:coplan_user, :admin) } before { sign_in_as(alice) } diff --git a/spec/requests/automated_reviews_spec.rb b/spec/requests/automated_reviews_spec.rb index 0c1c4b7..f47f4b8 100644 --- a/spec/requests/automated_reviews_spec.rb +++ b/spec/requests/automated_reviews_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe "AutomatedReviews", type: :request do - let(:user) { create(:user, :admin) } + let(:user) { create(:coplan_user, :admin) } let(:plan) { create(:plan, :considering, created_by_user: user) } let!(:reviewer) { create(:automated_plan_reviewer, enabled: true) } diff --git a/spec/requests/comment_threads_spec.rb b/spec/requests/comment_threads_spec.rb index d418b71..70fbc8e 100644 --- a/spec/requests/comment_threads_spec.rb +++ b/spec/requests/comment_threads_spec.rb @@ -1,8 +1,8 @@ require "rails_helper" RSpec.describe "CommentThreads", type: :request do - let(:alice) { create(:user, :admin) } - let(:bob) { create(:user) } + let(:alice) { create(:coplan_user, :admin) } + let(:bob) { create(:coplan_user) } let(:plan) { create(:plan, :considering, created_by_user: alice) } before { sign_in_as(alice) } diff --git a/spec/requests/comments_spec.rb b/spec/requests/comments_spec.rb index 7cd892d..be1e74c 100644 --- a/spec/requests/comments_spec.rb +++ b/spec/requests/comments_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe "Comments", type: :request do - let(:alice) { create(:user, :admin) } + let(:alice) { create(:coplan_user, :admin) } let(:plan) { create(:plan, :considering, created_by_user: alice) } let(:thread_record) { create(:comment_thread, plan: plan, plan_version: plan.current_plan_version, created_by_user: alice) } diff --git a/spec/requests/plans_spec.rb b/spec/requests/plans_spec.rb index 9632551..b8dde02 100644 --- a/spec/requests/plans_spec.rb +++ b/spec/requests/plans_spec.rb @@ -1,8 +1,8 @@ require "rails_helper" RSpec.describe "Plans", type: :request do - let(:alice) { create(:user, :admin) } - let(:bob) { create(:user) } + let(:alice) { create(:coplan_user, :admin) } + let(:bob) { create(:coplan_user) } let(:plan) { create(:plan, :considering, created_by_user: alice) } let(:brainstorm_plan) { create(:plan, :brainstorm, created_by_user: alice) } diff --git a/spec/services/plans/commit_session_spec.rb b/spec/services/plans/commit_session_spec.rb index bb1a5a9..6412b94 100644 --- a/spec/services/plans/commit_session_spec.rb +++ b/spec/services/plans/commit_session_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe CoPlan::Plans::CommitSession do - let(:user) { create(:user) } + let(:user) { create(:coplan_user) } let(:content) { "# My Plan\n\nFirst section content.\n\n## Goals\n\nWe should use unit tests.\n\n## Timeline\n\nQ1 2026 delivery." } let(:plan) do plan = CoPlan::Plan.create!(title: "Test Plan", created_by_user: user) diff --git a/spec/services/plans/create_spec.rb b/spec/services/plans/create_spec.rb index de9a71c..d17c618 100644 --- a/spec/services/plans/create_spec.rb +++ b/spec/services/plans/create_spec.rb @@ -2,7 +2,7 @@ RSpec.describe CoPlan::Plans::Create do it "creates plan with initial version" do - user = create(:user) + user = create(:coplan_user) plan = CoPlan::Plans::Create.call( title: "New Plan", content: "# New Plan\n\nSome content.", From 74d15633093a71fd0eb0a18730af65e9471ab424 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Thu, 26 Feb 2026 15:05:48 -0600 Subject: [PATCH 2/3] Fix host auth ordering and ambiguous FK removal - Skip host's authenticate_user! in CoPlan::ApplicationController so the overridden current_user doesn't break the parent's auth check - Use explicit column: args for all remove_foreign_key calls to avoid ambiguity when a table has multiple FKs to the same target - Update api_tokens_spec to expect 401 (CoPlan returns unauthorized directly instead of redirecting to host sign_in) --- ...20260226200001_create_coplan_users_and_migrate.rb | 12 ++++++------ .../app/controllers/coplan/application_controller.rb | 3 +++ spec/requests/api_tokens_spec.rb | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/db/migrate/20260226200001_create_coplan_users_and_migrate.rb b/db/migrate/20260226200001_create_coplan_users_and_migrate.rb index 38aec56..3f0f4eb 100644 --- a/db/migrate/20260226200001_create_coplan_users_and_migrate.rb +++ b/db/migrate/20260226200001_create_coplan_users_and_migrate.rb @@ -18,10 +18,10 @@ def up SQL # Update foreign keys to point to coplan_users instead of users - remove_foreign_key :coplan_api_tokens, :users + remove_foreign_key :coplan_api_tokens, column: :user_id remove_foreign_key :coplan_comment_threads, column: :created_by_user_id remove_foreign_key :coplan_comment_threads, column: :resolved_by_user_id - remove_foreign_key :coplan_plan_collaborators, :users + remove_foreign_key :coplan_plan_collaborators, column: :user_id remove_foreign_key :coplan_plan_collaborators, column: :added_by_user_id remove_foreign_key :coplan_plans, column: :created_by_user_id @@ -34,17 +34,17 @@ def up end def down - remove_foreign_key :coplan_api_tokens, :coplan_users + remove_foreign_key :coplan_api_tokens, column: :user_id remove_foreign_key :coplan_comment_threads, column: :created_by_user_id remove_foreign_key :coplan_comment_threads, column: :resolved_by_user_id - remove_foreign_key :coplan_plan_collaborators, :coplan_users + remove_foreign_key :coplan_plan_collaborators, column: :user_id remove_foreign_key :coplan_plan_collaborators, column: :added_by_user_id remove_foreign_key :coplan_plans, column: :created_by_user_id - add_foreign_key :coplan_api_tokens, :users + add_foreign_key :coplan_api_tokens, :users, column: :user_id add_foreign_key :coplan_comment_threads, :users, column: :created_by_user_id add_foreign_key :coplan_comment_threads, :users, column: :resolved_by_user_id - add_foreign_key :coplan_plan_collaborators, :users + add_foreign_key :coplan_plan_collaborators, :users, column: :user_id add_foreign_key :coplan_plan_collaborators, :users, column: :added_by_user_id add_foreign_key :coplan_plans, :users, column: :created_by_user_id diff --git a/engine/app/controllers/coplan/application_controller.rb b/engine/app/controllers/coplan/application_controller.rb index ff4e351..9cb4732 100644 --- a/engine/app/controllers/coplan/application_controller.rb +++ b/engine/app/controllers/coplan/application_controller.rb @@ -11,6 +11,9 @@ def self.controller_path helper CoPlan::MarkdownHelper helper CoPlan::CommentsHelper + # Skip host auth — CoPlan handles authentication internally via config.authenticate + skip_before_action :authenticate_user!, raise: false + before_action :authenticate_coplan_user! before_action :set_coplan_current diff --git a/spec/requests/api_tokens_spec.rb b/spec/requests/api_tokens_spec.rb index d3a77d3..f9392f6 100644 --- a/spec/requests/api_tokens_spec.rb +++ b/spec/requests/api_tokens_spec.rb @@ -39,6 +39,6 @@ it "requires authentication" do delete sign_out_path get settings_tokens_path - expect(response).to redirect_to(sign_in_path) + expect(response).to have_http_status(:unauthorized) end end From 740b790e711fee1588e810ad8996ee5eedb9f5d0 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 27 Feb 2026 09:44:48 -0600 Subject: [PATCH 3/3] Handle race condition in user auto-provisioning If two concurrent requests try to create the same CoPlan::User (by external_id), the unique index causes a RecordNotUnique on the loser. Rescue and re-find the existing record, then sync attributes. --- .../app/controllers/coplan/application_controller.rb | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/engine/app/controllers/coplan/application_controller.rb b/engine/app/controllers/coplan/application_controller.rb index 9cb4732..bd15304 100644 --- a/engine/app/controllers/coplan/application_controller.rb +++ b/engine/app/controllers/coplan/application_controller.rb @@ -47,9 +47,16 @@ def authenticate_coplan_user! return end - @current_coplan_user = CoPlan::User.find_or_initialize_by(external_id: attrs[:external_id].to_s) + external_id = attrs[:external_id].to_s + @current_coplan_user = CoPlan::User.find_or_initialize_by(external_id: external_id) @current_coplan_user.assign_attributes(attrs.slice(:name, :admin, :metadata).compact) - @current_coplan_user.save! if @current_coplan_user.new_record? || @current_coplan_user.changed? + if @current_coplan_user.new_record? || @current_coplan_user.changed? + @current_coplan_user.save! + end + rescue ActiveRecord::RecordNotUnique + @current_coplan_user = CoPlan::User.find_by!(external_id: external_id) + @current_coplan_user.assign_attributes(attrs.slice(:name, :admin, :metadata).compact) + @current_coplan_user.save! if @current_coplan_user.changed? end def set_coplan_current