rails-markup 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +304 -0
  4. data/app/assets/javascripts/rails_markup/toolbar.js +2225 -0
  5. data/app/controllers/rails_markup/annotations_controller.rb +244 -0
  6. data/app/controllers/rails_markup/application_controller.rb +7 -0
  7. data/app/controllers/rails_markup/dashboard_controller.rb +230 -0
  8. data/app/controllers/rails_markup/external/annotations_controller.rb +68 -0
  9. data/app/models/rails_markup/annotation.rb +176 -0
  10. data/app/views/layouts/rails_markup/application.html.erb +158 -0
  11. data/app/views/rails_markup/dashboard/_annotation_card.html.erb +25 -0
  12. data/app/views/rails_markup/dashboard/_annotation_list.html.erb +57 -0
  13. data/app/views/rails_markup/dashboard/_annotation_page.html.erb +13 -0
  14. data/app/views/rails_markup/dashboard/_filters.html.erb +75 -0
  15. data/app/views/rails_markup/dashboard/_stats.html.erb +22 -0
  16. data/app/views/rails_markup/dashboard/_styles.html.erb +115 -0
  17. data/app/views/rails_markup/dashboard/board.html.erb +135 -0
  18. data/app/views/rails_markup/dashboard/index.html.erb +38 -0
  19. data/app/views/rails_markup/dashboard/show.html.erb +129 -0
  20. data/app/views/rails_markup/shared/_toolbar.html.erb +28 -0
  21. data/bin/rails-markup +5 -0
  22. data/config/routes.rb +45 -0
  23. data/db/migrate/20260720000000_add_client_uuid_to_rails_markup_annotations.rb +13 -0
  24. data/db/migrate/20260721000000_backfill_rails_markup_client_uuids.rb +17 -0
  25. data/lib/generators/rails_markup/install/templates/auth_controller.rb.erb +17 -0
  26. data/lib/generators/rails_markup/install/templates/bin_markup.erb +5 -0
  27. data/lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb +27 -0
  28. data/lib/generators/rails_markup/install/templates/initializer.rb.erb +54 -0
  29. data/lib/generators/rails_markup/install_generator.rb +167 -0
  30. data/lib/generators/rails_markup/uninstall_generator.rb +110 -0
  31. data/lib/rails_markup/cli/base.rb +8 -0
  32. data/lib/rails_markup/cli/initializer_writer.rb +54 -0
  33. data/lib/rails_markup/cli/setup_wizard.rb +229 -0
  34. data/lib/rails_markup/cli.rb +831 -0
  35. data/lib/rails_markup/client_uuid_maintenance.rb +174 -0
  36. data/lib/rails_markup/configuration.rb +160 -0
  37. data/lib/rails_markup/engine.rb +18 -0
  38. data/lib/rails_markup/http_server.rb +226 -0
  39. data/lib/rails_markup/http_store_proxy.rb +122 -0
  40. data/lib/rails_markup/mcp_config.rb +258 -0
  41. data/lib/rails_markup/mcp_server.rb +639 -0
  42. data/lib/rails_markup/server.rb +73 -0
  43. data/lib/rails_markup/store.rb +219 -0
  44. data/lib/rails_markup/version.rb +5 -0
  45. data/lib/rails_markup.rb +11 -0
  46. data/lib/tasks/rails_markup_client_uuids.rake +19 -0
  47. metadata +198 -0
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMarkup
4
+ class AnnotationsController < RailsMarkup.config.base_controller_class.constantize
5
+ protect_from_forgery with: :exception
6
+
7
+ before_action :set_annotation, only: %i[acknowledge resolve dismiss reply]
8
+
9
+ rescue_from ActiveRecord::RecordNotFound do
10
+ render json: { error: "not found" }, status: :not_found
11
+ end
12
+
13
+ # POST /feedback/api/sessions
14
+ def create_session
15
+ render json: { id: "rm-#{SecureRandom.hex(8)}", url: params[:url] }
16
+ end
17
+
18
+ # POST /feedback/api/sessions/:session_id/annotations
19
+ def create
20
+ return render_invalid_metadata if client_supplied_author?
21
+
22
+ attributes = annotation_params
23
+ return render_invalid_legacy_client_id unless attributes
24
+
25
+ annotation = Annotation.new(attributes)
26
+ assign_current_user(annotation)
27
+
28
+ if annotation.client_uuid.present? && (existing = Annotation.find_by(client_uuid: annotation.client_uuid))
29
+ return render_duplicate(existing, annotation)
30
+ end
31
+
32
+ if annotation.save
33
+ fire_create_callback(annotation)
34
+ render json: annotation.as_api_json, status: :created
35
+ else
36
+ render json: { errors: annotation.errors.full_messages }, status: :unprocessable_entity
37
+ end
38
+ rescue ActiveRecord::RecordNotUnique
39
+ raise if annotation.client_uuid.blank?
40
+
41
+ render_duplicate(Annotation.find_by!(client_uuid: annotation.client_uuid), annotation)
42
+ end
43
+
44
+ # GET /feedback/api/annotations?page_url=/current?page=variant
45
+ def index
46
+ annotations = Annotation.for_page(params[:page_url]).recent.to_a
47
+ unless annotations.all? { |annotation| Annotation.valid_client_uuid?(annotation.client_uuid) }
48
+ return render json: { error: "annotation identities require repair" }, status: :service_unavailable
49
+ end
50
+
51
+ render json: annotations.map(&:as_api_json)
52
+ end
53
+
54
+ # PUT /feedback/api/annotations/:client_uuid
55
+ def upsert
56
+ client_uuid = normalized_route_uuid
57
+ return render_invalid_uuid unless client_uuid
58
+ return render_invalid_metadata if client_supplied_author?
59
+
60
+ dirty_fields = normalized_dirty_fields
61
+ return render_invalid_dirty_fields unless dirty_fields
62
+
63
+ attributes = browser_attributes
64
+ return render_invalid_status if dirty_fields.include?("status") && !Annotation::STATUSES.include?(attributes["status"])
65
+
66
+ annotation = Annotation.find_or_initialize_by(client_uuid: client_uuid)
67
+ created = annotation.new_record?
68
+ apply_desired_state(annotation, attributes, dirty_fields)
69
+ annotation.save!
70
+ fire_create_callback(annotation) if created
71
+ render json: annotation.as_api_json
72
+ rescue ActiveRecord::RecordInvalid => error
73
+ render json: { errors: error.record.errors.full_messages }, status: :unprocessable_entity
74
+ rescue ActiveRecord::RecordNotUnique
75
+ annotation = Annotation.find_by!(client_uuid: client_uuid)
76
+ apply_desired_state(annotation, attributes, dirty_fields)
77
+ annotation.save!
78
+ render json: annotation.as_api_json
79
+ end
80
+
81
+ # DELETE /feedback/api/annotations/:client_uuid
82
+ def destroy
83
+ client_uuid = normalized_route_uuid
84
+ return render_invalid_uuid unless client_uuid
85
+
86
+ Annotation.find_by(client_uuid: client_uuid)&.destroy!
87
+ head :no_content
88
+ end
89
+
90
+ # GET /feedback/api/health
91
+ def health
92
+ render json: { ok: true }
93
+ end
94
+
95
+ # POST /feedback/api/annotations/:id/acknowledge
96
+ def acknowledge
97
+ @annotation.acknowledge!
98
+ render json: @annotation.as_api_json
99
+ end
100
+
101
+ # POST /feedback/api/annotations/:id/resolve
102
+ def resolve
103
+ @annotation.resolve!(summary: params[:summary])
104
+ render json: @annotation.as_api_json
105
+ end
106
+
107
+ # POST /feedback/api/annotations/:id/dismiss
108
+ def dismiss
109
+ @annotation.dismiss!(reason: params[:reason])
110
+ render json: @annotation.as_api_json
111
+ end
112
+
113
+ # POST /feedback/api/annotations/:id/reply
114
+ def reply
115
+ return render json: { error: "message is required" }, status: :unprocessable_entity if params[:message].blank?
116
+
117
+ @annotation.add_reply!(message: params[:message], role: "agent")
118
+ render json: @annotation.as_api_json
119
+ end
120
+
121
+ private
122
+
123
+ def set_annotation
124
+ @annotation = Annotation.find(params[:id])
125
+ end
126
+
127
+ DEDUP_ATTRIBUTES = %w[user_id page_url content intent severity selected_text target metadata].freeze
128
+
129
+ def assign_current_user(annotation)
130
+ return unless respond_to?(:current_user, true) && current_user
131
+
132
+ annotation.user_id = current_user.id
133
+ author = RailsMarkup.config.resolve_author_name(current_user)
134
+ annotation.metadata = (annotation.metadata || {}).merge("author" => author) if author
135
+ end
136
+
137
+ def render_duplicate(existing, candidate)
138
+ if existing.attributes.slice(*DEDUP_ATTRIBUTES) == candidate.attributes.slice(*DEDUP_ATTRIBUTES)
139
+ render json: existing.as_api_json, status: :ok
140
+ else
141
+ render json: { error: "client id already used for a different annotation" }, status: :conflict
142
+ end
143
+ end
144
+
145
+ ALLOWED_TARGET_KEYS = %w[selector cssPath nearbyText boundingBox].freeze
146
+ ALLOWED_METADATA_KEYS = Annotation::BROWSER_METADATA_KEYS.freeze
147
+ ALLOWED_DIRTY_FIELDS = (Annotation::BROWSER_ATTRIBUTES + %w[metadata status]).freeze
148
+ DIRTY_FIELD_ALIASES = { "selectedText" => "selected_text" }.freeze
149
+
150
+ def fire_create_callback(annotation)
151
+ callback = RailsMarkup.config.on_create_callback
152
+ return unless callback.respond_to?(:call)
153
+
154
+ callback.call(annotation)
155
+ rescue => e
156
+ Rails.logger.error("[rails-markup] on_create_callback error: #{e.message}")
157
+ end
158
+
159
+ def annotation_params
160
+ permitted = params.permit(:page_url, :content, :intent, :severity, :selected_text, :selectedText, :clientId, target: {}, metadata: {})
161
+ permitted[:selected_text] ||= permitted.delete(:selectedText)
162
+ requested_client_uuid = permitted.delete(:clientId).to_s.strip
163
+ if (normalized_client_uuid = Annotation.normalize_client_uuid(requested_client_uuid))
164
+ permitted[:client_uuid] = normalized_client_uuid
165
+ elsif requested_client_uuid.present?
166
+ permitted[:client_uuid] = Annotation.legacy_client_uuid(
167
+ session_id: params[:session_id], legacy_client_id: requested_client_uuid
168
+ )
169
+ end
170
+ permitted[:page_url] ||= request.referer || "/"
171
+ permitted[:target] = normalize_target(params[:target]) if params[:target].present?
172
+ permitted[:metadata] = normalize_hash(params[:metadata], ALLOWED_METADATA_KEYS) if params[:metadata].present?
173
+ permitted
174
+ rescue ArgumentError
175
+ nil
176
+ end
177
+
178
+ def browser_attributes
179
+ permitted = params.permit(:page_url, :content, :intent, :severity, :status, :selected_text, :selectedText, target: {}, metadata: {})
180
+ permitted[:selected_text] ||= permitted.delete(:selectedText)
181
+ permitted[:target] = normalize_target(params[:target]) if params.key?(:target)
182
+ permitted[:metadata] = normalize_hash(params[:metadata], ALLOWED_METADATA_KEYS) if params.key?(:metadata)
183
+ permitted.to_h.stringify_keys
184
+ end
185
+
186
+ def apply_desired_state(annotation, attributes, dirty_fields)
187
+ assign_current_user(annotation) if annotation.new_record?
188
+ annotation.apply_browser_state(attributes, dirty_fields: dirty_fields)
189
+ end
190
+
191
+ def normalized_route_uuid
192
+ uuid = params[:client_uuid].to_s.strip
193
+ Annotation.normalize_client_uuid(uuid)
194
+ end
195
+
196
+ def normalized_dirty_fields
197
+ fields = params[:dirtyFields] || []
198
+ return unless fields.is_a?(Array)
199
+
200
+ fields = fields.map { |field| DIRTY_FIELD_ALIASES.fetch(field.to_s, field.to_s) }
201
+ fields if (fields - ALLOWED_DIRTY_FIELDS).empty?
202
+ end
203
+
204
+ def client_supplied_author?
205
+ metadata = params[:metadata]
206
+ metadata.respond_to?(:key?) && (metadata.key?(:author) || metadata.key?("author"))
207
+ end
208
+
209
+ def render_invalid_uuid
210
+ render json: { error: "client uuid must be a canonical UUID" }, status: :unprocessable_entity
211
+ end
212
+
213
+ def render_invalid_legacy_client_id
214
+ render json: { error: "legacy client id requires a valid session identity" }, status: :unprocessable_entity
215
+ end
216
+
217
+ def render_invalid_metadata
218
+ render json: { error: "author metadata is server owned" }, status: :unprocessable_entity
219
+ end
220
+
221
+ def render_invalid_dirty_fields
222
+ render json: { error: "invalid dirty fields" }, status: :unprocessable_entity
223
+ end
224
+
225
+ def render_invalid_status
226
+ render json: { error: "invalid status" }, status: :unprocessable_entity
227
+ end
228
+
229
+ def normalize_target(target)
230
+ case target
231
+ when String then { "selector" => target }
232
+ when ActionController::Parameters
233
+ target.permit(*ALLOWED_TARGET_KEYS, boundingBox: %i[x y top left width height]).to_h
234
+ else {}
235
+ end
236
+ end
237
+
238
+ def normalize_hash(value, allowed_keys)
239
+ return {} unless value.is_a?(ActionController::Parameters)
240
+
241
+ value.permit(*allowed_keys).to_h
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMarkup
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+ end
7
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module RailsMarkup
6
+ class DashboardController < RailsMarkup.config.base_controller_class.constantize
7
+ layout -> { RailsMarkup.config.dashboard_layout }
8
+
9
+ # When using a host app layout, expose host route helpers and all
10
+ # view helpers (icon, super_admin?, etc.) so the layout renders correctly.
11
+ if RailsMarkup.config.dashboard_layout != "rails_markup/application"
12
+ base = RailsMarkup.config.base_controller_class.constantize
13
+ helper base._helpers
14
+
15
+ # Delegate unknown *_path/*_url helpers to main_app so host routes
16
+ # don't get the engine mount prefix (/admin/annotations) prepended.
17
+ host_routes = Module.new do
18
+ def method_missing(method, *args, **kwargs, &block)
19
+ if method.to_s.match?(/_(path|url)\z/) && main_app.respond_to?(method)
20
+ main_app.public_send(method, *args, **kwargs, &block)
21
+ else
22
+ super
23
+ end
24
+ end
25
+
26
+ def respond_to_missing?(method, include_private = false)
27
+ (method.to_s.match?(/_(path|url)\z/) && main_app.respond_to?(method)) || super
28
+ end
29
+ end
30
+ helper host_routes
31
+
32
+ # Include helpers that gems add directly to ActionView::Base
33
+ ActionView::Base.included_modules.each do |mod|
34
+ next unless mod.is_a?(Module) && mod.name&.include?("Helper")
35
+ next if mod.name.start_with?("RailsMarkup")
36
+ helper mod rescue nil
37
+ end
38
+ end
39
+
40
+ ALLOWED_STATUSES = %w[all pending acknowledged resolved dismissed].freeze
41
+ ALLOWED_ROLES = %w[agent user].freeze
42
+
43
+ before_action :set_annotation, only: %i[show update]
44
+
45
+ # GET /feedback
46
+ def index
47
+ @current_status = ALLOWED_STATUSES.include?(params[:status]) ? params[:status] : "pending"
48
+ base_scope = build_base_scope
49
+
50
+ # Single grouped count query instead of 6 separate queries
51
+ counts = base_scope.group(:status).count
52
+ @total_count = counts.values.sum
53
+ @pending_count = counts["pending"] || 0
54
+ @acknowledged_count = counts["acknowledged"] || 0
55
+ @resolved_count = counts["resolved"] || 0
56
+ @dismissed_count = counts["dismissed"] || 0
57
+
58
+ paginate(filtered_scope)
59
+
60
+ @page_urls = Annotation.distinct.pluck(:page_url).sort
61
+ @current_page_url = params[:page_url]
62
+ @authors = Annotation.distinct_authors
63
+ @current_author = params[:author]
64
+ @current_query = params[:q]
65
+ end
66
+
67
+ # GET /feedback/annotations/:id
68
+ def show
69
+ end
70
+
71
+ # GET /feedback/load_more
72
+ def load_more
73
+ @current_status = ALLOWED_STATUSES.include?(params[:status]) ? params[:status] : "pending"
74
+ @current_page_url = params[:page_url]
75
+ @current_author = params[:author]
76
+ @current_query = params[:q]
77
+
78
+ # load_more only ever fetches the NEXT page. A missing/invalid cursor
79
+ # (stale ?page= link, malformed cursor) must not silently re-serve page
80
+ # one, which would append duplicate cards. Return an empty page instead.
81
+ if valid_cursor?
82
+ paginate(filtered_scope)
83
+ else
84
+ @annotations = []
85
+ @next_page = false
86
+ @remaining = 0
87
+ end
88
+
89
+ render partial: "annotation_page", layout: false
90
+ end
91
+
92
+ # GET /feedback/board
93
+ def board
94
+ @columns = {
95
+ pending: Annotation.pending.recent.limit(50),
96
+ acknowledged: Annotation.acknowledged.recent.limit(50),
97
+ resolved: Annotation.resolved.recent.limit(20),
98
+ dismissed: Annotation.dismissed.recent.limit(20)
99
+ }
100
+ end
101
+
102
+ # POST /feedback/dismiss_all
103
+ def dismiss_all
104
+ status = params[:status]
105
+ unless status.in?(%w[pending acknowledged])
106
+ return redirect_to root_path, alert: "Invalid status for bulk dismiss."
107
+ end
108
+
109
+ count = Annotation.where(status: status).update_all(status: "dismissed")
110
+ redirect_to root_path(status: "dismissed"), notice: "#{count} annotations dismissed."
111
+ end
112
+
113
+ # PATCH /feedback/annotations/:id
114
+ def update
115
+ case params[:action_type]
116
+ when "acknowledge" then @annotation.acknowledge!
117
+ when "resolve" then @annotation.resolve!(summary: params[:summary].presence)
118
+ when "dismiss" then @annotation.dismiss!(reason: params[:reason].presence)
119
+ when "reply"
120
+ role = ALLOWED_ROLES.include?(params[:role]) ? params[:role] : "agent"
121
+ @annotation.add_reply!(message: params[:message], role: role)
122
+ when "transition"
123
+ new_status = params[:status]
124
+ if Annotation::STATUSES.include?(new_status)
125
+ @annotation.update!(status: new_status)
126
+ return head :ok
127
+ else
128
+ return render json: { error: "invalid status" }, status: :unprocessable_entity
129
+ end
130
+ else
131
+ return redirect_to annotation_path(@annotation), alert: "Unknown action."
132
+ end
133
+
134
+ redirect_to annotation_path(@annotation), notice: "Annotation updated."
135
+ end
136
+
137
+ # GET /feedback/export.csv
138
+ def export_csv
139
+ scope = build_export_scope
140
+ csv_data = generate_csv(scope)
141
+ send_data csv_data, filename: "annotations-#{Date.current}.csv", type: "text/csv"
142
+ end
143
+
144
+ # GET /feedback/export.json
145
+ def export_json
146
+ scope = build_export_scope
147
+ json_data = scope.map(&:as_api_json).to_json
148
+ send_data json_data, filename: "annotations-#{Date.current}.json", type: "application/json"
149
+ end
150
+
151
+ private
152
+
153
+ def set_annotation
154
+ @annotation = Annotation.find(params[:id])
155
+ end
156
+
157
+ def per_page
158
+ RailsMarkup.config.per_page
159
+ end
160
+
161
+ def build_base_scope
162
+ params[:page_url].present? ? Annotation.for_page(params[:page_url]) : Annotation.all
163
+ end
164
+
165
+ # Shared filtered, ordered scope for index + load_more. Requires @current_status.
166
+ def filtered_scope
167
+ scope = build_base_scope.recent
168
+ scope = scope.where(status: @current_status) unless @current_status == "all"
169
+ scope = scope.search(params[:q]) if params[:q].present?
170
+ scope = scope.by_author(params[:author]) if params[:author].present?
171
+ scope
172
+ end
173
+
174
+ # Keyset pagination: fetch one extra row to detect "more", and expose a
175
+ # (created_at, id) cursor for the next page instead of a page/offset — so a
176
+ # row inserted between requests can't push a boundary row onto two pages.
177
+ def paginate(scope)
178
+ cursor = scope
179
+ before_time = parse_cursor_time(params[:before_time])
180
+ if before_time && params[:before_id].present?
181
+ cursor = cursor.before_cursor(before_time, params[:before_id].to_i)
182
+ end
183
+
184
+ rows = cursor.limit(per_page + 1).to_a
185
+ @next_page = rows.size > per_page
186
+ @annotations = rows.first(per_page)
187
+
188
+ if @next_page && @annotations.any?
189
+ last = @annotations.last
190
+ @next_time = last.created_at.iso8601(6)
191
+ @next_id = last.id
192
+ @remaining = scope.before_cursor(last.created_at, last.id).count
193
+ else
194
+ @next_page = false
195
+ @next_time = @next_id = nil
196
+ @remaining = 0
197
+ end
198
+ end
199
+
200
+ def parse_cursor_time(value)
201
+ return nil if value.blank?
202
+
203
+ Time.zone.parse(value.to_s)
204
+ rescue ArgumentError
205
+ nil
206
+ end
207
+
208
+ def valid_cursor?
209
+ params[:before_id].present? && parse_cursor_time(params[:before_time]).present?
210
+ end
211
+
212
+ def build_export_scope
213
+ scope = build_base_scope.recent
214
+ scope = scope.where(status: params[:status]) if params[:status].present? && params[:status] != "all"
215
+ scope = scope.search(params[:q]) if params[:q].present?
216
+ scope = scope.by_author(params[:author]) if params[:author].present?
217
+ scope
218
+ end
219
+
220
+ def generate_csv(scope)
221
+ CSV.generate(headers: true) do |csv|
222
+ csv << %w[id status intent severity content page_url author selected_text created_at updated_at]
223
+ scope.find_each do |ann|
224
+ csv << [ann.id, ann.status, ann.intent, ann.severity, ann.content, ann.page_url,
225
+ ann.author_name, ann.selected_text, ann.created_at.iso8601, ann.updated_at.iso8601]
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMarkup
4
+ module External
5
+ class AnnotationsController < ActionController::API
6
+ before_action :authenticate_token!
7
+ before_action :set_annotation, only: %i[show acknowledge resolve dismiss reply]
8
+
9
+ rescue_from ActiveRecord::RecordNotFound do
10
+ render json: { error: "not found" }, status: :not_found
11
+ end
12
+
13
+ # GET /external/pending
14
+ def pending
15
+ annotations = Annotation.pending.recent.limit(50)
16
+ render json: { annotations: annotations.map(&:as_api_json) }
17
+ end
18
+
19
+ # GET /external/:id
20
+ def show
21
+ render json: @annotation.as_api_json
22
+ end
23
+
24
+ # PATCH /external/:id/acknowledge
25
+ def acknowledge
26
+ @annotation.acknowledge!
27
+ render json: @annotation.as_api_json
28
+ end
29
+
30
+ # PATCH /external/:id/resolve
31
+ def resolve
32
+ @annotation.resolve!(summary: params[:summary])
33
+ render json: @annotation.as_api_json
34
+ end
35
+
36
+ # PATCH /external/:id/dismiss
37
+ def dismiss
38
+ @annotation.dismiss!(reason: params[:reason])
39
+ render json: @annotation.as_api_json
40
+ end
41
+
42
+ # PATCH /external/:id/reply
43
+ def reply
44
+ return render json: { error: "message is required" }, status: :unprocessable_entity if params[:message].blank?
45
+
46
+ @annotation.add_reply!(message: params[:message], role: "agent")
47
+ render json: @annotation.as_api_json
48
+ end
49
+
50
+ private
51
+
52
+ def set_annotation
53
+ @annotation = Annotation.find(params[:id])
54
+ end
55
+
56
+ def authenticate_token!
57
+ # Development — allow all requests without token (dev server may bind to LAN IP)
58
+ return if Rails.env.development?
59
+
60
+ token = RailsMarkup.config.api_token
61
+ return head(:not_found) if token.nil?
62
+
63
+ provided = request.headers["Authorization"]&.delete_prefix("Bearer ")
64
+ head(:unauthorized) unless ActiveSupport::SecurityUtils.secure_compare(provided.to_s, token)
65
+ end
66
+ end
67
+ end
68
+ end