rails_ai_admin 0.1.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 (193) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +458 -0
  3. data/LICENSE +21 -0
  4. data/README.md +292 -0
  5. data/SECURITY.md +14 -0
  6. data/THIRD_PARTY_NOTICES.md +19 -0
  7. data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
  8. data/app/assets/builds/rails_ai_admin.css +345 -0
  9. data/app/assets/builds/rails_ai_admin.js +33 -0
  10. data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
  11. data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
  12. data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
  13. data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
  14. data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
  15. data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
  16. data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
  17. data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
  18. data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
  19. data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
  20. data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
  21. data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
  22. data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
  23. data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
  24. data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
  25. data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
  26. data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
  27. data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
  28. data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
  29. data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
  30. data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
  31. data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
  32. data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
  33. data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
  34. data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
  35. data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
  36. data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
  37. data/app/models/rails_ai_admin/application_record.rb +8 -0
  38. data/app/models/rails_ai_admin/audit_event.rb +12 -0
  39. data/app/models/rails_ai_admin/batch_item.rb +8 -0
  40. data/app/models/rails_ai_admin/operation_run.rb +20 -0
  41. data/app/models/rails_ai_admin/selection.rb +9 -0
  42. data/app/models/rails_ai_admin/selection_item.rb +9 -0
  43. data/app/models/rails_ai_admin/work_item.rb +18 -0
  44. data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
  45. data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
  46. data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
  47. data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
  48. data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
  49. data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
  50. data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
  51. data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
  52. data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
  53. data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
  54. data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
  55. data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
  56. data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
  57. data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
  58. data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
  59. data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
  60. data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
  61. data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
  62. data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
  63. data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
  64. data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
  65. data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
  66. data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
  67. data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
  68. data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
  69. data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
  70. data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
  71. data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
  72. data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
  73. data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
  74. data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
  75. data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
  76. data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
  77. data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
  78. data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
  79. data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
  80. data/app/views/rails_ai_admin/search/index.html.erb +20 -0
  81. data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
  82. data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
  83. data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
  84. data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
  85. data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
  86. data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
  87. data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
  88. data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
  89. data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
  90. data/config/locales/en.yml +549 -0
  91. data/config/routes.rb +64 -0
  92. data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
  93. data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
  94. data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
  95. data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
  96. data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
  97. data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
  98. data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
  99. data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
  100. data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
  101. data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
  102. data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
  103. data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
  104. data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
  105. data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
  106. data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
  107. data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
  108. data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
  109. data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
  110. data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
  111. data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
  112. data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
  113. data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
  114. data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
  115. data/lib/rails_ai_admin/assistant/records.rb +71 -0
  116. data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
  117. data/lib/rails_ai_admin/assistant/tools.rb +477 -0
  118. data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
  119. data/lib/rails_ai_admin/assistant.rb +50 -0
  120. data/lib/rails_ai_admin/authorization/access.rb +296 -0
  121. data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
  122. data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
  123. data/lib/rails_ai_admin/collections/claim.rb +91 -0
  124. data/lib/rails_ai_admin/collections/contract.rb +113 -0
  125. data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
  126. data/lib/rails_ai_admin/collections/queries.rb +79 -0
  127. data/lib/rails_ai_admin/collections/result.rb +15 -0
  128. data/lib/rails_ai_admin/collections/runner.rb +208 -0
  129. data/lib/rails_ai_admin/configuration.rb +178 -0
  130. data/lib/rails_ai_admin/context.rb +29 -0
  131. data/lib/rails_ai_admin/doctor.rb +301 -0
  132. data/lib/rails_ai_admin/engine.rb +24 -0
  133. data/lib/rails_ai_admin/error.rb +16 -0
  134. data/lib/rails_ai_admin/field.rb +625 -0
  135. data/lib/rails_ai_admin/fields/association.rb +188 -0
  136. data/lib/rails_ai_admin/fields/computation.rb +43 -0
  137. data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
  138. data/lib/rails_ai_admin/fields/file_value.rb +23 -0
  139. data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
  140. data/lib/rails_ai_admin/fields/projection.rb +78 -0
  141. data/lib/rails_ai_admin/fields/registry.rb +48 -0
  142. data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
  143. data/lib/rails_ai_admin/fields/type.rb +50 -0
  144. data/lib/rails_ai_admin/forms/plan.rb +90 -0
  145. data/lib/rails_ai_admin/input_schema.rb +76 -0
  146. data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
  147. data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
  148. data/lib/rails_ai_admin/mcp.rb +130 -0
  149. data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
  150. data/lib/rails_ai_admin/navigation/menu.rb +31 -0
  151. data/lib/rails_ai_admin/navigation/settings.rb +28 -0
  152. data/lib/rails_ai_admin/operation.rb +120 -0
  153. data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
  154. data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
  155. data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
  156. data/lib/rails_ai_admin/operations/registry.rb +80 -0
  157. data/lib/rails_ai_admin/operations/result.rb +21 -0
  158. data/lib/rails_ai_admin/operations/runner.rb +633 -0
  159. data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
  160. data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
  161. data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
  162. data/lib/rails_ai_admin/outbox/registry.rb +36 -0
  163. data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
  164. data/lib/rails_ai_admin/outbox/writer.rb +43 -0
  165. data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
  166. data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
  167. data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
  168. data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
  169. data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
  170. data/lib/rails_ai_admin/presentation/history.rb +46 -0
  171. data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
  172. data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
  173. data/lib/rails_ai_admin/presentation/status.rb +40 -0
  174. data/lib/rails_ai_admin/prune.rb +61 -0
  175. data/lib/rails_ai_admin/queries/list.rb +162 -0
  176. data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
  177. data/lib/rails_ai_admin/record_version.rb +40 -0
  178. data/lib/rails_ai_admin/registry.rb +45 -0
  179. data/lib/rails_ai_admin/request_log_filter.rb +24 -0
  180. data/lib/rails_ai_admin/resource.rb +249 -0
  181. data/lib/rails_ai_admin/resource_identity.rb +15 -0
  182. data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
  183. data/lib/rails_ai_admin/selections/builder.rb +162 -0
  184. data/lib/rails_ai_admin/templates/catalog.rb +84 -0
  185. data/lib/rails_ai_admin/templates/inspector.rb +94 -0
  186. data/lib/rails_ai_admin/templates/metadata.rb +48 -0
  187. data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
  188. data/lib/rails_ai_admin/test_kit.rb +103 -0
  189. data/lib/rails_ai_admin/version.rb +5 -0
  190. data/lib/rails_ai_admin/work.rb +48 -0
  191. data/lib/rails_ai_admin.rb +112 -0
  192. data/lib/tasks/rails_ai_admin.rake +58 -0
  193. metadata +328 -0
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Resolves fresh host authority for every request without altering host classes.
5
+ class ApplicationController < ActionController::Base
6
+ # Routing metadata is useful; arbitrary inputs and search text are not logs.
7
+ LOG_FILTER = /\A(?!(?:controller|action|resource|id|format)\z)/
8
+ protect_from_forgery with: :exception
9
+ wrap_parameters false
10
+ layout "rails_ai_admin/application"
11
+ helper RailsAiAdmin::FieldsHelper
12
+ helper RailsAiAdmin::NavigationHelper
13
+ helper RailsAiAdmin::FiltersHelper
14
+ helper RailsAiAdmin::ToolViewsHelper
15
+ helper RailsAiAdmin::HistoryHelper
16
+ helper RailsAiAdmin::DashboardHelper
17
+ prepend_before_action :secure_headers
18
+ before_action :authenticate_admin
19
+ after_action :notice_discarded_list_settings
20
+ helper_method :resource_navigation, :admin_csp_nonce, :list_page_path, :encoded_return_state, :navigation_state, :navigation_params, :list_return_path, :list_return_label, :return_state_discarded?, :return_navigation_fields, :list_state_fields, :visible_resources, :allowed_action?, :record_label, :collection_jobs_available?, :admin_associations, :assistant_available?, :searchable_resources, :audit_log_available?, :actor_labels, :assistant_for?
21
+
22
+ rescue_from AccessDenied, with: :deny_access
23
+ rescue_from ConfigurationError do
24
+ admin_status(:unavailable)
25
+ end
26
+ rescue_from ActionController::InvalidAuthenticityToken do
27
+ mark_form_recovery
28
+ admin_status(:invalid_request)
29
+ end
30
+
31
+ protected
32
+
33
+ # Layouts loading the prebuilt Turbo bundle need this nonce in csp-nonce meta.
34
+ def admin_csp_nonce
35
+ raise ConfigurationError, "Admin security headers have not been prepared" unless @admin_csp_nonce.is_a?(String) && @admin_csp_nonce.present?
36
+ String.new(@admin_csp_nonce).freeze
37
+ end
38
+
39
+ # Like Rails render, this does not halt an action. Callers must return when
40
+ # selecting a status instead of continuing toward business work.
41
+ def admin_status(kind)
42
+ @admin_status = Presentation::Status.fetch(kind)
43
+ response.headers.delete("X-RailsAiAdmin-Form-Recovery") if response.headers["X-RailsAiAdmin-Form-Recovery"] == "ready"
44
+ @status_sign_in_path = nil
45
+ if @admin_status.kind == "authentication_required"
46
+ path = RailsAiAdmin.configuration.authentication_path
47
+ @status_sign_in_path = String.new(path).freeze if path.is_a?(String) && path.valid_encoding? && path.ascii_only? && path.match?(Configuration::INTERNAL_PATH)
48
+ end
49
+ if request.format.html? || request.format.turbo_stream?
50
+ begin
51
+ render template: "rails_ai_admin/statuses/show", layout: "rails_ai_admin/status", formats: [ :html ],
52
+ content_type: "text/html", status: @admin_status.http_status
53
+ rescue StandardError
54
+ self.response_body = nil
55
+ render plain: @admin_status.message, content_type: "text/plain", status: @admin_status.http_status
56
+ end
57
+ else
58
+ render plain: @admin_status.message, status: @admin_status.http_status
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def process_action(*)
65
+ filters = Array(request.get_header("action_dispatch.parameter_filter")) + [ LOG_FILTER ]
66
+ request.set_header("action_dispatch.parameter_filter", filters)
67
+ super
68
+ end
69
+
70
+ def secure_headers
71
+ @admin_csp_nonce = SecureRandom.base64(24)
72
+ response.headers["Cache-Control"] = "no-store, private"
73
+ response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' 'nonce-#{@admin_csp_nonce}'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"
74
+ response.headers["X-Content-Type-Options"] = "nosniff"
75
+ response.headers["Referrer-Policy"] = "same-origin"
76
+ end
77
+
78
+ def authenticate_admin
79
+ name = RailsAiAdmin.configuration.context_adapter
80
+ @context = name&.constantize&.new&.resolve(self)
81
+ raise AccessDenied, "UNAUTHENTICATED" unless @context.is_a?(Context)
82
+ end
83
+
84
+ def deny_access(error)
85
+ status = Presentation::Status.for_code(error.code)
86
+ if status.kind == "authentication_required" && (target = sign_in_redirect)
87
+ return redirect_to(target, status: :see_other)
88
+ end
89
+ mark_form_recovery if status.kind == "authentication_required"
90
+ admin_status(status.kind)
91
+ end
92
+
93
+ # A plain page visit by a signed-out person goes to the host's sign-in
94
+ # page with the requested path (RAA-024). Form submissions, recovery,
95
+ # frames and non-HTML requests keep the 401, so no open form is lost. The
96
+ # query string is left out: it can carry search text. The host must still
97
+ # validate return_to before redirecting to it.
98
+ def sign_in_redirect
99
+ path = RailsAiAdmin.configuration.authentication_path
100
+ return unless path.is_a?(String) && path.match?(Configuration::INTERNAL_PATH)
101
+ return unless request.get? && request.format.html? && !request.xhr?
102
+ return if request.headers["Turbo-Frame"] || request.headers["X-RailsAiAdmin-Recovery-Intent"]
103
+ requested = request.path
104
+ return path unless requested.is_a?(String) && requested.match?(%r{\A/(?![/\\])[^\s]*\z}) && requested.length <= 2000
105
+ "#{path}?#{{ return_to: requested }.to_query}"
106
+ end
107
+
108
+ def mark_form_recovery
109
+ if is_a?(ResourcesController) && %w[create update].include?(action_name)
110
+ response.headers["X-RailsAiAdmin-Form-Recovery"] = "session"
111
+ end
112
+ end
113
+
114
+ def resolve_list_navigation
115
+ raw = params[:return_to]
116
+ raw = raw.to_unsafe_h if raw.is_a?(ActionController::Parameters)
117
+ access = Authorization::Access.new(@context, @resource)
118
+ @list_navigation = if action_name == "recover_form"
119
+ Navigation::ListState.from_header(access, request.headers["X-RailsAiAdmin-Return-State"])
120
+ else
121
+ Navigation::ListState.new(access, raw)
122
+ end
123
+ end
124
+
125
+ def encoded_return_state = @list_navigation&.encoded || ""
126
+ def navigation_state = @list_navigation&.state || {}
127
+ def navigation_params = navigation_state.empty? ? {} : { return_to: navigation_state }
128
+ def return_state_discarded? = @list_navigation&.discarded? == true
129
+
130
+ def list_return_path
131
+ @list_navigation&.index_allowed? ? resources_path(resource: @resource.key, **navigation_state.symbolize_keys) : root_path
132
+ end
133
+
134
+ def list_page_path(page)
135
+ raise ConfigurationError, "Invalid list page" unless page.is_a?(Integer) && (1..1000).cover?(page)
136
+ state = page == 1 ? navigation_state.except("page") : navigation_state.merge("page" => page.to_s)
137
+ resources_path(resource: @resource.key, **state.symbolize_keys)
138
+ end
139
+
140
+ def list_return_label = @list_navigation&.index_allowed? ? @resource.label : t("rails_ai_admin.layout.administration")
141
+
142
+ def return_navigation_fields
143
+ helpers.safe_join(Navigation::ListState.form_pairs(navigation_state, "return_to").map { |name, value| helpers.hidden_field_tag(name, value, id: nil) })
144
+ end
145
+
146
+ # Hidden inputs that keep selected list settings in another GET list form.
147
+ def list_state_fields(*keys)
148
+ helpers.safe_join(Navigation::ListState.form_pairs(navigation_state.slice(*keys)).map { |name, value| helpers.hidden_field_tag(name, value, id: nil) })
149
+ end
150
+
151
+ def notice_discarded_list_settings
152
+ return unless response.redirect? && return_state_discarded?
153
+ message = t("rails_ai_admin.layout.settings_unavailable")
154
+ flash[:notice] = [ flash[:notice], message ].compact.join(" ") unless flash[:notice].to_s.include?(message)
155
+ end
156
+
157
+ # Request-only memoization; no menu/grant snapshot survives authentication.
158
+ def resource_navigation
159
+ @resource_navigation ||= Navigation::Menu.build(context: @context,
160
+ resources: RailsAiAdmin.registry.resources, current_key: @resource&.key)
161
+ end
162
+
163
+ def visible_resources
164
+ RailsAiAdmin.registry.resources.select do |resource|
165
+ Authorization::Access.new(@context, resource).authorize!(:index)
166
+ rescue AccessDenied
167
+ false
168
+ end
169
+ end
170
+
171
+ # Resources in the menu that this actor may list and search, for the
172
+ # header search box. Request-local; no grant survives the request.
173
+ def searchable_resources
174
+ @searchable_resources ||= RailsAiAdmin.registry.resources.select do |resource|
175
+ next false if resource.navigation.hidden
176
+ access = Authorization::Access.new(@context, resource)
177
+ access.authorize!(:index)
178
+ access.fields(:search).any?
179
+ rescue AccessDenied
180
+ false
181
+ end
182
+ end
183
+
184
+ def allowed_action?(resource, action, record = nil)
185
+ Authorization::Access.new(@context, resource).authorize!(action, record)
186
+ rescue AccessDenied
187
+ false
188
+ end
189
+
190
+ # Whether this page may offer "Ask the assistant" for a resource.
191
+ def assistant_for?(resource)
192
+ return false unless assistant_available?
193
+ Authorization::Access.new(@context, resource).authorize_assistant!
194
+ rescue AccessDenied
195
+ false
196
+ end
197
+
198
+ def audit_log_available?
199
+ return @audit_log_available unless @audit_log_available.nil?
200
+ @audit_log_available = RailsAiAdmin.registry.resources.any? do |resource|
201
+ Authorization::Access.new(@context, resource).authorize_audit_log!
202
+ rescue AccessDenied
203
+ false
204
+ end
205
+ end
206
+
207
+ def collection_jobs_available?
208
+ RailsAiAdmin.registry.resources.any? do |resource|
209
+ RailsAiAdmin.operation_registry.custom(resource).any? do |definition|
210
+ next false unless definition.scope == :collection
211
+ Authorization::Access.new(@context, resource).authorize_receipt!(definition.key, nil)
212
+ rescue AccessDenied
213
+ false
214
+ end
215
+ end
216
+ end
217
+
218
+ # The menu link follows the same grant as the assistant pages.
219
+ def assistant_available?
220
+ return false unless RailsAiAdmin.configuration.assistant
221
+ require "rails_ai_admin/assistant"
222
+ @assistant_available = Assistant.available?(@context) if @assistant_available.nil?
223
+ @assistant_available
224
+ end
225
+
226
+ # Request-local reference cache. Each target is read through its own
227
+ # resource policy; nothing is shared across requests or actors.
228
+ def admin_associations
229
+ @admin_associations ||= Fields::Association.new(@context, @resource)
230
+ end
231
+
232
+ # Display names for stored actor references. The context adapter may
233
+ # answer actor_labels(context:, actor_refs:) with a Hash; otherwise a
234
+ # reference reads as "User #42".
235
+ def actor_labels(refs)
236
+ adapter = RailsAiAdmin.configuration.context_adapter&.constantize&.new
237
+ given = adapter.respond_to?(:actor_labels) ? adapter.actor_labels(context: @context, actor_refs: refs) : {}
238
+ given = {} unless given.is_a?(Hash)
239
+ refs.to_h do |ref|
240
+ label = given[ref]
241
+ label = nil unless label.is_a?(String) && label.valid_encoding? && label.length.between?(1, 200)
242
+ [ ref, label || default_actor_label(ref) ]
243
+ end
244
+ end
245
+
246
+ def default_actor_label(ref)
247
+ _, _, kind, id = ref.to_s.split(":", 4)
248
+ kind && id ? "#{kind.demodulize.titleize} ##{id}" : ref.to_s
249
+ end
250
+
251
+ def record_label(resource, record, values: nil, view: :show)
252
+ Authorization::Access.new(@context, resource).record_label(record, values: values, view: view)
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Admin Chat pages. A request only saves a message as an inbox turn and
5
+ # enqueues work; no provider call runs in a web request. RubyLLM is loaded
6
+ # inside actions only, so conventional hosts can eager-load this file.
7
+ class AssistantController < ApplicationController
8
+ RETRYABLE = %w[PROVIDER_UNAVAILABLE ASSISTANT_FAILED ATTEMPTS_EXHAUSTED AI_UNAVAILABLE STEP_LIMIT].freeze
9
+ FAILURES = {
10
+ "AI_UNAVAILABLE" => "The assistant is not available right now. The rest of the administration works as usual.",
11
+ "AI_REJECTED" => "The AI provider rejected this request. Try a shorter or different message.",
12
+ "PROVIDER_UNAVAILABLE" => "The AI provider did not respond. Your message is saved; try again.",
13
+ "ASSISTANT_FAILED" => "The assistant could not finish. Your message is saved; try again.",
14
+ "ATTEMPTS_EXHAUSTED" => "The assistant stopped after several attempts. Your message is saved; try again.",
15
+ "STEP_LIMIT" => "The assistant stopped before finishing this request. Try a narrower question.",
16
+ "AUTHORIZATION_REVOKED" => "Your access changed, so this request was not answered.",
17
+ "NOT_ALLOWED" => "No resources are available to the assistant for your account.",
18
+ "CANCELLED" => "Stopped."
19
+ }.freeze
20
+
21
+ before_action :require_assistant
22
+ before_action :load_chat, except: %i[index create]
23
+ around_action :bind_surface_context
24
+
25
+ def index
26
+ @chats = Assistant::Chat.for_owner(@context).order(updated_at: :desc, id: :desc).limit(50).to_a
27
+ end
28
+
29
+ # A new chat; from a record or list page it starts with the question and a
30
+ # server-written "Context:" line naming that page.
31
+ def create
32
+ question = params[:content]
33
+ raise AccessDenied, "INVALID_QUERY" unless question.nil? || question.is_a?(String)
34
+ chat = Assistant.create_chat!(@context)
35
+ if question.present?
36
+ content = [ page_context_line, question.strip ].compact.join("\n\n").first(Assistant::Coordinator::MAX_CONTENT)
37
+ Assistant::Coordinator.admit!(chat, kind: "message", content: content, admission_key: "message:#{SecureRandom.uuid}")
38
+ end
39
+ redirect_to assistant_conversation_path(chat_ref: chat.ref), status: :see_other
40
+ rescue ::RubyLLM::ModelNotFoundError, ::RubyLLM::ModelRegistryError
41
+ admin_status(:unavailable)
42
+ end
43
+
44
+ def show
45
+ prepare_transcript
46
+ if turbo_frame_request?
47
+ # A complete standards-mode document; Turbo extracts the matching frame.
48
+ render :frame, layout: "rails_ai_admin/frame"
49
+ else
50
+ @admission_key = "message:#{SecureRandom.uuid}"
51
+ end
52
+ end
53
+
54
+ def message
55
+ return admin_status(:conflict) unless @chat.current_for?(@context)
56
+ Assistant::Coordinator.admit!(@chat, kind: "message", content: params[:content], admission_key: params[:admission_key])
57
+ redirect_to assistant_conversation_path(chat_ref: @chat.ref), status: :see_other
58
+ rescue AccessDenied => error
59
+ raise unless %w[INVALID_QUERY RATE_LIMITED].include?(error.code)
60
+ prepare_transcript
61
+ @admission_key = params[:admission_key].is_a?(String) && params[:admission_key].match?(Assistant::Coordinator::ADMISSION_KEY) ? params[:admission_key] : "message:#{SecureRandom.uuid}"
62
+ @draft = params[:content].is_a?(String) ? params[:content].first(Assistant::Coordinator::MAX_CONTENT + 1) : ""
63
+ if error.code == "RATE_LIMITED"
64
+ @message_error = t("rails_ai_admin.assistant.rate_limited", count: RailsAiAdmin.configuration.assistant_daily_message_limit)
65
+ return render :show, status: :too_many_requests
66
+ end
67
+ @message_error = t("rails_ai_admin.assistant.invalid_message", count: Assistant::Coordinator::MAX_CONTENT)
68
+ render :show, status: :unprocessable_content
69
+ end
70
+
71
+ # Stops waiting turns now and asks a running one to stop at its next
72
+ # checkpoint. Committed business results are never undone.
73
+ def cancel
74
+ Assistant::Turn.transaction do
75
+ Assistant::Chat.lock.find(@chat.id)
76
+ @chat.turns.where(status: "queued").update_all(status: "cancelled", error_code: "CANCELLED", completed_at: Time.current, updated_at: Time.current)
77
+ @chat.cancel if @chat.turns.where(status: "running").exists?
78
+ end
79
+ redirect_to assistant_conversation_path(chat_ref: @chat.ref), status: :see_other
80
+ end
81
+
82
+ # Requeues the latest failed turn. The saved human message is reused, so
83
+ # retrying never repeats it in the conversation.
84
+ def retry_turn
85
+ return admin_status(:conflict) unless @chat.current_for?(@context)
86
+ Assistant::Turn.transaction do
87
+ Assistant::Chat.lock.find(@chat.id)
88
+ turn = @chat.turns.reorder(sequence: :desc).first
89
+ raise AccessDenied, "CONFLICT" unless turn&.status == "failed" && RETRYABLE.include?(turn.error_code)
90
+ turn.update!(status: "queued", attempts: 0, error_code: nil, next_attempt_at: nil, completed_at: nil)
91
+ end
92
+ Assistant::Coordinator.enqueue_after_commit(@chat.ref)
93
+ redirect_to assistant_conversation_path(chat_ref: @chat.ref), status: :see_other
94
+ end
95
+
96
+ def archive
97
+ @chat.update!(status: "archived")
98
+ redirect_to assistant_path, status: :see_other, notice: t("rails_ai_admin.assistant.archived")
99
+ end
100
+
101
+ private
102
+
103
+ # Names the page only when the actor may show it to the assistant now. The
104
+ # line is data for the model and grants nothing; tools check again.
105
+ def page_context_line
106
+ about = params[:about]
107
+ return unless about.is_a?(ActionController::Parameters)
108
+ resource = RailsAiAdmin.registry.fetch(about[:resource].to_s)
109
+ access = Authorization::Access.new(@context, resource)
110
+ access.authorize_assistant!
111
+ id = about[:id]
112
+ if id.is_a?(String) && id.present?
113
+ raise AccessDenied unless access.canonical_id?(id)
114
+ record = access.find!(id)
115
+ "Context: I am looking at #{resource.label.singularize} #{record.id} (\"#{access.record_label(record).truncate(120)}\") in the #{resource.key} resource."
116
+ else
117
+ query = about[:q].is_a?(String) ? about[:q].strip.first(100) : nil
118
+ scope = about[:scope].is_a?(String) ? access.named_scopes.find { |definition| definition.name.to_s == about[:scope] } : nil
119
+ details = [ (query.present? ? "searching for \"#{query}\"" : nil), (scope ? "on the #{scope.label} tab (scope #{scope.name})" : nil) ].compact
120
+ "Context: I am looking at the #{resource.label} list (the #{resource.key} resource)#{details.any? ? ", #{details.join(', ')}" : ''}."
121
+ end
122
+ rescue AccessDenied, ConfigurationError, KeyError
123
+ nil
124
+ end
125
+
126
+ def require_assistant
127
+ raise AccessDenied unless RailsAiAdmin.configuration.assistant
128
+ require "rails_ai_admin/assistant"
129
+ raise AccessDenied unless Assistant.available?(@context)
130
+ end
131
+
132
+ def load_chat
133
+ @chat = Assistant::Chat.owned(@context, params[:chat_ref]) || raise(AccessDenied)
134
+ end
135
+
136
+ # Surfaces in a conversation render through the admin-bound context.
137
+ def bind_surface_context(&block)
138
+ return yield unless RailsAiAdmin.configuration.surfaces
139
+ require "rails_ai_admin/integrations/tool_views"
140
+ Integrations::ToolViews.ensure_registered!
141
+ Integrations::ToolViews.with_context(@context, &block)
142
+ end
143
+
144
+ # A security change closes the chat: its transcript, including old tool
145
+ # output, is no longer shown under the new authority.
146
+ def prepare_transcript
147
+ @current = @chat.current_for?(@context)
148
+ @entries = @current ? Assistant::Transcript.new(@context, @chat).entries : []
149
+ last = @chat.turns.reorder(sequence: :desc).first
150
+ @pending = @current && @chat.turns.where(status: Assistant::Turn::ACTIVE).exists?
151
+ running = @pending ? @chat.turns.where(status: "running").reorder(:sequence).first : nil
152
+ @running = !running.nil?
153
+ # The reply being written, shown only under the chat's current authority.
154
+ @streaming_text = running&.draft.presence
155
+ retrying = @pending && @chat.turns.where(status: "queued").where.not(error_code: nil).reorder(:sequence).first
156
+ @retry_status = t("rails_ai_admin.assistant.retrying", attempt: retrying.attempts + 1, attempts: Assistant::Coordinator::MAX_ATTEMPTS) if retrying
157
+ @failure = last if last&.status == "failed" || last&.status == "cancelled"
158
+ code = @failure && (FAILURES.key?(@failure.error_code.to_s) ? @failure.error_code.to_s : "ASSISTANT_FAILED")
159
+ @failure_message = code && t(code.downcase, scope: %i[rails_ai_admin assistant failures], default: FAILURES.fetch(code))
160
+ @retryable = @current && @failure&.status == "failed" && RETRYABLE.include?(@failure.error_code)
161
+ @surface_scope = Integrations::ToolViews.scope_for(@context) if RailsAiAdmin.configuration.surfaces
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Recent changes across records for the resources the actor may audit.
5
+ class AuditController < ApplicationController
6
+ FILTERS = %w[resource operation status actor before].freeze
7
+
8
+ def index
9
+ @log = Presentation::AuditLog.new(@context)
10
+ @filters = FILTERS.to_h { |key| [ key, params[key] ] }.compact_blank
11
+ raise AccessDenied, "INVALID_QUERY" unless @filters.values.all? { |value| value.is_a?(String) }
12
+ @page = @log.page(**@filters.transform_keys(&:to_sym))
13
+ @actor_labels = actor_labels(@page.entries.map(&:actor_ref).uniq | [ @filters["actor"] ].compact)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ class CollectionJobsController < ApplicationController
5
+ # The page exists only where its menu link does (RAA-030).
6
+ def index
7
+ raise AccessDenied unless collection_jobs_available?
8
+ @jobs = Collections::Queries.new.recent(context: @context)
9
+ end
10
+
11
+ def show
12
+ @job = Collections::Queries.new.fetch(context: @context, job_ref: params[:job_ref])
13
+ @resource = RailsAiAdmin.registry.fetch(@job.resource_key)
14
+ resolve_list_navigation
15
+ end
16
+
17
+ def cancel
18
+ run = OperationRun.find_by(id: params[:job_ref], actor_ref: @context.actor_ref, tenant_ref: @context.tenant_ref)
19
+ raise AccessDenied unless run
20
+ @resource = RailsAiAdmin.registry.fetch(run.resource_key)
21
+ resolve_list_navigation
22
+ result = Collections::Runner.new.cancel(context: @context, job_ref: params[:job_ref])
23
+ return render plain: t("rails_ai_admin.collection_jobs.busy"), status: :conflict if result.code == "CONFLICT"
24
+ if result.code == "OUTCOME_UNKNOWN"
25
+ return redirect_to collection_job_path(job_ref: result.job_ref, **navigation_params), status: :see_other,
26
+ notice: t("rails_ai_admin.collection_jobs.cancel_unknown")
27
+ end
28
+ raise AccessDenied unless result.job_ref
29
+ redirect_to collection_job_path(job_ref: result.job_ref, **navigation_params), status: :see_other,
30
+ notice: t("rails_ai_admin.collection_jobs.cancelled")
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ class CollectionOperationsController < ApplicationController
5
+ before_action :resolve_operation
6
+
7
+ def new
8
+ load_form
9
+ @form_values, @errors = {}, {}
10
+ end
11
+
12
+ def preflight
13
+ input = submitted_input
14
+ @proposal = Collections::Runner.new.preflight(context: @context, resource: @resource, operation: @definition.key,
15
+ selection_ref: params[:selection_ref], input: input)
16
+ if @proposal.is_a?(Collections::Result)
17
+ render_rejection(@proposal, input)
18
+ else
19
+ @wire_input = @proposal.intent.input_payload.to_json
20
+ render :confirmation
21
+ end
22
+ end
23
+
24
+ def execute
25
+ input = confirmed_input
26
+ result = Collections::Runner.new.admit(context: @context, intent_ref: params[:intent_ref], input: input,
27
+ human_confirmed: true, binding: { resource_key: @resource.key, operation: @definition.key, selection_id: params[:selection_ref] })
28
+ if result.accepted?
29
+ redirect_to collection_job_path(job_ref: result.job_ref, **navigation_params), status: :see_other, notice: t("rails_ai_admin.collection_operations.admitted")
30
+ else
31
+ render_rejection(result, input)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def resolve_operation
38
+ @resource = RailsAiAdmin.registry.fetch(params[:resource])
39
+ @definition = RailsAiAdmin.operation_registry.fetch(@resource, params[:operation])
40
+ raise AccessDenied unless @definition.custom? && @definition.scope == :collection
41
+ @access = Authorization::Access.new(@context, @resource)
42
+ resolve_list_navigation
43
+ end
44
+
45
+ def load_form
46
+ @selection = Selections::Builder.new.revalidate(context: @context, selection_ref: params[:selection_ref], resource: @resource)
47
+ @access.authorize!(@definition.key)
48
+ @form_fields = @definition.fields(@access, nil)
49
+ end
50
+
51
+ def submitted_input
52
+ value = params[:input]
53
+ raise AccessDenied, "INVALID_QUERY" unless value.nil? || value.is_a?(ActionController::Parameters)
54
+ value ? value.to_unsafe_h : {}
55
+ end
56
+
57
+ def confirmed_input
58
+ value = params[:confirmed_input]
59
+ raise AccessDenied, "INVALID_QUERY" unless value.is_a?(String) && value.bytesize <= 16_384
60
+ JSON.parse(value)
61
+ rescue JSON::ParserError
62
+ raise AccessDenied, "INVALID_QUERY"
63
+ end
64
+
65
+ def render_rejection(result, input)
66
+ raise AccessDenied if result.code == "NOT_FOUND"
67
+ @failure_code = result.code
68
+ @uncertain_job_ref = result.job_ref if result.code == "OUTCOME_UNKNOWN"
69
+ if result.code == "VALIDATION_FAILED"
70
+ load_form
71
+ @form_values = input.is_a?(Hash) ? input.stringify_keys.slice(*@form_fields.map { |field| field.name.to_s }).reject { |_, value| value.is_a?(Array) || value.is_a?(Hash) } : {}
72
+ @errors = result.errors
73
+ render :new, status: :unprocessable_content
74
+ else
75
+ render :failure, status: %w[TIMEOUT OPERATION_FAILED].include?(result.code) ? :service_unavailable : :conflict
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Public base for host-owned Rails routes, controllers and partials. Hosts keep
5
+ # their own route helpers/layout; admin engine links use the mounted proxy.
6
+ class CustomController < ApplicationController
7
+ layout false
8
+ helper_method :admin_routes
9
+
10
+ protected
11
+
12
+ def admin_context
13
+ raise AccessDenied, "UNAUTHENTICATED" unless @context.is_a?(Context)
14
+ @context
15
+ end
16
+
17
+ # Resolving an access object grants nothing: explicitly authorize, use its
18
+ # scoped find/relation and project permitted values before displaying them.
19
+ def admin_access(resource_key)
20
+ Authorization::Access.new(admin_context, RailsAiAdmin.registry.fetch(resource_key))
21
+ end
22
+
23
+ def admin_operation_runner = Operations::Runner.new
24
+ def admin_routes = rails_ai_admin
25
+ end
26
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ class DashboardController < ApplicationController # :nodoc:
5
+ # Counts appear only for resources whose list already opts into exact
6
+ # counts, with the same bound (10,000) and default list tab.
7
+ def index
8
+ @widgets = Presentation::Dashboard.build(@context, RailsAiAdmin.registry.resources.reject { |resource| resource.navigation.hidden })
9
+ @counts = RailsAiAdmin.registry.resources.each_with_object({}) do |resource, counts|
10
+ next unless resource.index.count == :exact
11
+ counts[resource.key.to_s] = Queries::List.new(@context, resource).count
12
+ rescue AccessDenied
13
+ next
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module RailsAiAdmin
6
+ # Downloads the current list as CSV. POST keeps GET requests free of the
7
+ # audit write and lets the CSRF token guard the download.
8
+ class ExportsController < ApplicationController
9
+ def create
10
+ @resource = RailsAiAdmin.registry.fetch(params[:resource])
11
+ access = Authorization::Access.new(@context, @resource)
12
+ access.authorize_export!
13
+ raw = params.to_unsafe_h.slice(*Navigation::ListState::KEYS).except("page")
14
+ @list_navigation = Navigation::ListState.new(access, raw, strict: true)
15
+ raise AccessDenied, "INVALID_QUERY" if @list_navigation.discarded?
16
+ state = @list_navigation.state
17
+ result = Presentation::CsvExport.new(@context, @resource).call(search: state["q"], sort: state["sort"],
18
+ direction: state.fetch("direction", "asc"), scope: state["scope"], filters: state["filter"])
19
+ if result.too_many
20
+ redirect_to resources_path(resource: @resource.key, **state.symbolize_keys), status: :see_other,
21
+ notice: t("rails_ai_admin.exports.too_many", limit: RailsAiAdmin.configuration.export_limit)
22
+ return
23
+ end
24
+ AuditEvent.create!(actor_ref: @context.actor_ref, tenant_ref: @context.tenant_ref, resource_key: @resource.key.to_s,
25
+ operation: "export", status: "succeeded", code: "OK", correlation_ref: SecureRandom.uuid, origin: "ui",
26
+ changed_fields: result.fields, created_at: Time.current)
27
+ filename = "#{@resource.key}-#{Time.current.in_time_zone(@context.time_zone).strftime('%Y%m%d-%H%M')}.csv"
28
+ send_data result.body, filename: filename, type: "text/csv; charset=utf-8", disposition: "attachment"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Serves an attached file after the same checks as the record's page: the
5
+ # record in scope, its show grant and a current read grant for the field.
6
+ # Only raster images are shown inline; everything else downloads.
7
+ class FilesController < ApplicationController
8
+ def show
9
+ raise AccessDenied unless defined?(::ActiveStorage::Blob)
10
+ resource = RailsAiAdmin.registry.fetch(params[:resource])
11
+ access = Authorization::Access.new(@context, resource)
12
+ raise AccessDenied unless access.canonical_id?(params[:id])
13
+ record = access.find!(params[:id])
14
+ field = access.fields(:read, record).find { |candidate| Field::FILE_TYPES.include?(candidate.scalar_type) && candidate.name.to_s == params[:field] }
15
+ raise AccessDenied unless field
16
+ attached = access.attachment(record, field)
17
+ blob = if field.scalar_type == :files
18
+ id = params[:attachment]
19
+ raise AccessDenied unless id.is_a?(String) && id.match?(Field::REFERENCE)
20
+ access.stored_attachments(record, field).find_by(id: id)&.blob
21
+ else
22
+ attached.attached? ? attached.blob : nil
23
+ end
24
+ raise AccessDenied unless blob
25
+ inline = params[:disposition] == "inline" && Fields::INLINE_IMAGE_TYPES.include?(blob.content_type)
26
+ # A served file is a document of its own: no script, no framing.
27
+ response.headers["Content-Security-Policy"] = "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox"
28
+ response.headers["Cache-Control"] = "no-store, private"
29
+ response.headers["Content-Type"] = inline ? blob.content_type : "application/octet-stream"
30
+ response.headers["Content-Disposition"] = ActionDispatch::Http::ContentDisposition.format(
31
+ disposition: inline ? "inline" : "attachment", filename: blob.filename.sanitized)
32
+ response.headers["Content-Length"] = blob.byte_size.to_s
33
+ # Streamed in the service's chunks; a large file is never held in memory.
34
+ self.response_body = Enumerator.new { |body| blob.download { |chunk| body << chunk } }
35
+ end
36
+ end
37
+ end