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,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Fields
5
+ # Resolves belongs_to references only through the target resource's current
6
+ # policy relation, record show grant and title. A target that is missing, in
7
+ # another tenant or not shown to this actor is indistinguishable: pages show
8
+ # only the already-readable key, and writes reject it with one message.
9
+ #
10
+ # One instance is a request-local cache. It never survives authentication.
11
+ class Association
12
+ MAX_CHOICES = 100
13
+ MAX_BATCH = 500
14
+ UNAVAILABLE = "is not an available choice"
15
+ INTEGER_KEY = /\A[1-9]\d{0,18}\z/
16
+ # visible is true only for a target in the current relation with a show grant.
17
+ Reference = Data.define(:id, :label, :resource_key, :visible)
18
+ # complete is false when the picker would exceed MAX_CHOICES or the actor
19
+ # cannot list the target resource; forms then accept a typed key instead.
20
+ Choices = Data.define(:options, :complete)
21
+
22
+ class << self
23
+ def target(field, registry: RailsAiAdmin.registry)
24
+ raise ConfigurationError, "Expected a belongs_to or references field" unless field.is_a?(Field) && Field::REFERENCE_TYPES.include?(field.scalar_type)
25
+ key = field.options.fetch(:resource).to_s
26
+ registry.resources.find { |resource| resource.key.to_s == key } ||
27
+ raise(ConfigurationError, "belongs_to #{field.name} names an unregistered resource")
28
+ end
29
+
30
+ # The declaration must match a non-polymorphic model belongs_to whose
31
+ # foreign key is the declared column and whose primary key is the target
32
+ # resource's single primary key. Nothing here queries domain rows.
33
+ def validate!(resource, field, registry: RailsAiAdmin.registry)
34
+ target = target(field, registry: registry)
35
+ return validate_collection!(resource, field, target) if field.scalar_type == :references
36
+ target_model = target.model_class
37
+ reflection = resource.model_class.reflect_on_association(field.name)
38
+ valid = reflection && reflection.macro == :belongs_to && !reflection.polymorphic? &&
39
+ reflection.foreign_key.to_s == field.column && target_model.primary_key.is_a?(String) &&
40
+ reflection.klass == target_model && reflection.association_primary_key.to_s == target_model.primary_key
41
+ unless valid
42
+ raise ConfigurationError, "belongs_to #{field.name} must match a non-polymorphic model association on #{field.column} to the #{target.key} primary key"
43
+ end
44
+ target
45
+ rescue NameError, ArgumentError
46
+ raise ConfigurationError, "belongs_to #{field.name} could not resolve its model association", cause: nil
47
+ end
48
+
49
+ # A references field must match a non-polymorphic has_many (including
50
+ # through) or has_and_belongs_to_many to the target model, whose
51
+ # <singular>_ids writer is the declared foreign_key.
52
+ def validate_collection!(resource, field, target)
53
+ target_model = target.model_class
54
+ reflection = resource.model_class.reflect_on_association(field.name)
55
+ valid = reflection && %i[has_many has_and_belongs_to_many].include?(reflection.macro) && !reflection.polymorphic? &&
56
+ reflection.klass == target_model && target_model.primary_key.is_a?(String) &&
57
+ "#{reflection.name.to_s.singularize}_ids" == field.column && resource.model_class.method_defined?("#{field.column}=")
58
+ unless valid
59
+ raise ConfigurationError, "references #{field.name} must match a has_many or has_and_belongs_to_many association to the #{target.key} model with #{field.column}"
60
+ end
61
+ target
62
+ rescue NameError, ArgumentError
63
+ raise ConfigurationError, "references #{field.name} could not resolve its model association", cause: nil
64
+ end
65
+ end
66
+
67
+ # resource: the declaring resource. Its own fields are checked against the
68
+ # model reflection; operation input fields only need a registered target.
69
+ def initialize(context, resource = nil)
70
+ raise AccessDenied, "UNAUTHENTICATED" unless context.is_a?(Context)
71
+ @context, @resource = context, resource
72
+ @accesses = {}
73
+ @references = Hash.new { |hash, key| hash[key] = {} }
74
+ end
75
+
76
+ # Loads every unresolved key for field with one bounded target query, so
77
+ # an index page costs one query per association column, not per row.
78
+ def prime(field, ids)
79
+ cache = @references[cache_key(field)]
80
+ pending = ids.compact.map(&:to_s).uniq.reject { |id| cache.key?(id) }
81
+ return self if pending.empty?
82
+ raise ConfigurationError, "Too many references to resolve at once" if pending.size > MAX_BATCH
83
+ access = target_access(field)
84
+ model = access.resource.model_class
85
+ keys = pending.select { |id| key_shape?(model, id) }
86
+ found = keys.empty? ? {} : access.relation.where(model.primary_key => keys).to_a.index_by { |record| record.id.to_s }
87
+ pending.each { |id| cache[id] = build(field, access, found[id], id) }
88
+ self
89
+ end
90
+
91
+ def reference(field, id)
92
+ return if id.nil?
93
+ prime(field, [ id ])
94
+ @references[cache_key(field)].fetch(id.to_s)
95
+ end
96
+
97
+ def admissible?(field, id) = reference(field, id)&.visible == true
98
+
99
+ # Current picker options as [label, key] pairs. Order follows the target
100
+ # title only while that title is a granted sort field, then the key.
101
+ def choices(field)
102
+ access = target_access(field)
103
+ begin
104
+ access.authorize!(:index)
105
+ rescue AccessDenied
106
+ return Choices.new([].freeze, false)
107
+ end
108
+ model = access.resource.model_class
109
+ table = model.arel_table
110
+ title = access.fields(:sort).find { |candidate| candidate.name == access.resource.title }
111
+ relation = access.relation.reorder(nil)
112
+ relation = relation.order(table[title.column].asc) if title
113
+ records = relation.order(table[model.primary_key].asc).limit(MAX_CHOICES + 1).to_a
114
+ cache = @references[cache_key(field)]
115
+ options = records.first(MAX_CHOICES).filter_map do |record|
116
+ reference = cache[record.id.to_s] ||= build(field, access, record, record.id.to_s)
117
+ [ reference.label, reference.id ] if reference.visible
118
+ end
119
+ Choices.new(options.freeze, records.size <= MAX_CHOICES)
120
+ end
121
+
122
+ # Picker search over a target resource, for forms and filters whose
123
+ # choices are incomplete. Results are exactly what the target's own index
124
+ # would show this actor for the same term: its policy relation, its
125
+ # granted search fields, and a show grant per record. A term shaped like
126
+ # the target's primary key also matches that record. Returns [label, key]
127
+ # pairs, at most limit.
128
+ SEARCH_LIMIT = 20
129
+ def self.search(context, target, term, limit: SEARCH_LIMIT)
130
+ raise ConfigurationError, "Invalid picker limit" unless limit.is_a?(Integer) && limit.between?(1, SEARCH_LIMIT)
131
+ access = Authorization::Access.new(context, target)
132
+ access.authorize!(:index)
133
+ term = term.strip if term.is_a?(String) && term.valid_encoding?
134
+ return [] if term.blank?
135
+ model = target.model_class
136
+ exact = access.canonical_id?(term) ? access.relation.where(model.primary_key => term).limit(1).to_a : []
137
+ matches = if access.fields(:search).any?
138
+ Queries::List.new(context, target).call(search: term, scope: Queries::NamedScope::NONE, page_size: limit).records
139
+ else
140
+ # Unsearchable targets still accept an exact key; the term itself
141
+ # is validated the same way the list validates search text.
142
+ raise AccessDenied, "INVALID_QUERY" unless Queries::List::FILTER_VALUE.call(term)
143
+ []
144
+ end
145
+ (exact + matches).uniq(&:id).first(limit).filter_map do |record|
146
+ access.authorize!(:show, record)
147
+ [ access.record_label(record).freeze, record.id.to_s.freeze ]
148
+ rescue AccessDenied
149
+ nil
150
+ end
151
+ end
152
+
153
+ private
154
+
155
+ def cache_key(field) = field.options.fetch(:resource).to_s
156
+
157
+ def target_access(field)
158
+ @accesses[cache_key(field)] ||= begin
159
+ own = @resource&.fields&.any? { |candidate| candidate.equal?(field) }
160
+ target = own ? self.class.validate!(@resource, field) : self.class.target(field)
161
+ Authorization::Access.new(@context, target)
162
+ end
163
+ end
164
+
165
+ # Integer keys are checked before the query so malformed text never reaches
166
+ # an integer comparison; other key types rely on the column's own cast.
167
+ def key_shape?(model, id)
168
+ return false unless id.match?(Field::REFERENCE)
169
+ column = model.columns_hash[model.primary_key]
170
+ column&.type == :integer ? id.match?(INTEGER_KEY) && Integer(id, 10) <= 2**63 - 1 : true
171
+ end
172
+
173
+ def build(field, access, record, id)
174
+ if record
175
+ access.authorize!(:show, record)
176
+ return Reference.new(id.dup.freeze, access.record_label(record).freeze, access.resource.key, true)
177
+ end
178
+ unavailable(field, id)
179
+ rescue AccessDenied
180
+ unavailable(field, id)
181
+ end
182
+
183
+ def unavailable(field, id)
184
+ Reference.new(id.dup.freeze, "#{self.class.target(field).label.singularize} ##{id}".freeze, nil, false)
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Fields
5
+ # Trusted pure host code receives only declared, currently authorized scalar
6
+ # dependencies. It cannot acquire authority from the field declaration.
7
+ class Computation
8
+ extend HookSignatures
9
+ class << self
10
+ def version(value = nil)
11
+ @version = value if value
12
+ @version || "1"
13
+ end
14
+
15
+ def metadata
16
+ unless name && version.is_a?(String) && version.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}\z/)
17
+ raise ConfigurationError, "Computation needs a named class and bounded version"
18
+ end
19
+ unless respond_to?(:new) && accepts?(method(:new), 0, []) && accepts?(instance_method(:initialize), 0, []) &&
20
+ public_instance_methods.include?(:call) && instance_method(:call).owner != Computation && accepts?(instance_method(:call), 1, %i[field time_zone])
21
+ raise ConfigurationError, "Computation has an incompatible public constructor or call signature"
22
+ end
23
+ { handler: name, version: version }
24
+ end
25
+ end
26
+
27
+ def call(_values, field:, time_zone:)
28
+ raise NotImplementedError
29
+ end
30
+
31
+ Definition = Data.define(:handler_name) do
32
+ def handler_class
33
+ klass = handler_name.constantize
34
+ raise ConfigurationError, "Computed field requires a Computation subclass" unless klass.is_a?(Class) && klass < Computation
35
+ klass
36
+ rescue NameError
37
+ raise ConfigurationError, "Computed field class could not be loaded", cause: nil
38
+ end
39
+ def metadata = handler_class.metadata
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Fields
5
+ module DependencyGraph
6
+ MAX_FIELDS = 128
7
+ MAX_DEPTH = 8
8
+ MAX_DEPENDENCIES = 20
9
+
10
+ def self.validate!(fields)
11
+ return unless fields.any?(&:virtual?)
12
+ raise ConfigurationError, "Computed resource exceeds field bound" if fields.size > MAX_FIELDS
13
+ declared = fields.index_by(&:name)
14
+ depths = {}
15
+ visit = lambda do |field, path|
16
+ return 0 unless field.virtual?
17
+ return depths[field.name] if depths.key?(field.name)
18
+ raise ConfigurationError, "Computed dependency cycle" if path.include?(field.name)
19
+ depth = 1 + (field.dependencies.map do |name|
20
+ dependency = declared.fetch(name) { raise ConfigurationError, "Computed dependency must be a declared field" }
21
+ visit.call(dependency, path + [ field.name ])
22
+ end.max || 0)
23
+ raise ConfigurationError, "Computed dependency depth exceeds bound" if depth > MAX_DEPTH
24
+ depths[field.name] = depth
25
+ end
26
+ fields.select(&:virtual?).each { |field| visit.call(field, []) }
27
+ end
28
+
29
+ def self.readable(fields)
30
+ granted = fields.index_by(&:name)
31
+ eligible = {}
32
+ visit = lambda do |field, path|
33
+ return eligible[field.name] if eligible.key?(field.name)
34
+ return eligible[field.name] = true unless field.virtual?
35
+ raise ConfigurationError, "Invalid computed dependency graph" if path.include?(field.name) || path.size >= MAX_DEPTH
36
+ eligible[field.name] = field.dependencies.all? do |name|
37
+ dependency = granted[name]
38
+ dependency && visit.call(dependency, path + [ field.name ])
39
+ end
40
+ end
41
+ fields.select { |field| visit.call(field, []) }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Fields
5
+ # Raster images a browser may show inline. SVG and HTML never are: they
6
+ # can carry script.
7
+ INLINE_IMAGE_TYPES = %w[image/png image/jpeg image/gif image/webp image/avif].freeze
8
+
9
+ # A chosen file the page refused before uploading it (too large).
10
+ RejectedUpload = Data.define(:reason, :limit)
11
+
12
+ # The readable facts about one attached file. Pages link to it through the
13
+ # admin's own download route, never through a public blob URL.
14
+ # id is the attachment id of one of several files (has_many_attached).
15
+ FileValue = Data.define(:filename, :content_type, :byte_size, :checksum, :id) do
16
+ def self.from(blob, id: nil)
17
+ new(blob.filename.to_s.freeze, blob.content_type.to_s.freeze, blob.byte_size, blob.checksum.to_s.freeze, id&.to_s&.freeze)
18
+ end
19
+
20
+ def image? = INLINE_IMAGE_TYPES.include?(content_type)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Fields
5
+ module HookSignatures
6
+ private
7
+
8
+ def accepts?(method, count, keywords)
9
+ params = method.parameters
10
+ minimum = params.count { |kind, _| kind == :req }
11
+ maximum = params.any? { |kind, _| kind == :rest } ? Float::INFINITY : minimum + params.count { |kind, _| kind == :opt }
12
+ (minimum..maximum).cover?(count) && (params.filter_map { |kind, key| key if kind == :keyreq } - keywords).empty? &&
13
+ !params.any? { |kind, _| kind == :nokey } &&
14
+ (params.any? { |kind, _| kind == :keyrest } || (keywords - params.filter_map { |kind, key| key if %i[key keyreq].include?(kind) }).empty?)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Fields
5
+ # One current projection, with no cross-request/identity value cache.
6
+ class Projection
7
+ MAX_VALUE_BYTES = 16_384
8
+ MAX_OUTPUT_BYTES = 65_536
9
+ MAX_SECONDS = 1.0
10
+
11
+ def initialize(access, record, fields)
12
+ @access, @record = access, record
13
+ @fields = fields.index_by(&:name)
14
+ @computed = fields.any?(&:virtual?)
15
+ @cache = {}
16
+ @bytes = 0
17
+ @deadline = monotonic + MAX_SECONDS
18
+ end
19
+
20
+ def values(fields: @fields.values)
21
+ result = fields.to_h { |field| [ field.name.to_s, value(field) ] }
22
+ check_deadline!
23
+ result
24
+ end
25
+
26
+ def value(field)
27
+ check_deadline!
28
+ raise AccessDenied unless @fields[field.name].equal?(field)
29
+ return @cache[field.name] if @cache.key?(field.name)
30
+ unless field.virtual?
31
+ scalar = @access.read_attribute(@record, field)
32
+ check_deadline!
33
+ return @cache[field.name] = scalar
34
+ end
35
+ computed = ActiveRecord::Base.while_preventing_writes do
36
+ dependencies = field.dependencies.to_h { |name| [ name.to_s, snapshot(value(@fields.fetch(name))) ] }.freeze
37
+ check_bound!(dependencies)
38
+ field.compute(dependencies, time_zone: @access.context.time_zone)
39
+ end
40
+ check_deadline!
41
+ @bytes += check_bound!(computed)
42
+ raise ConfigurationError, "Computed projection exceeds output bound" if @bytes > MAX_OUTPUT_BYTES
43
+ @cache[field.name] = computed
44
+ end
45
+
46
+ private
47
+
48
+ def snapshot(value)
49
+ copy = case value
50
+ when nil, true, false, Integer then value
51
+ when String then String.new(value)
52
+ when BigDecimal
53
+ raise ConfigurationError, "Computed dependencies require finite decimals" unless value.finite?
54
+ value
55
+ when Time, ActiveSupport::TimeWithZone, DateTime then value.to_time.getutc
56
+ when Date then Date.iso8601(value.iso8601)
57
+ else raise ConfigurationError, "Computed dependencies require supported scalars"
58
+ end
59
+ check_bound!(copy)
60
+ copy.freeze
61
+ end
62
+
63
+ def check_bound!(value)
64
+ bytes = JSON.generate(InputSchema.canonical(value)).bytesize
65
+ raise ConfigurationError, "Computed scalar exceeds size bound" if bytes > MAX_VALUE_BYTES
66
+ bytes
67
+ rescue JSON::GeneratorError, EncodingError
68
+ raise ConfigurationError, "Computed scalar encoding is invalid", cause: nil
69
+ end
70
+
71
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
72
+
73
+ def check_deadline!
74
+ raise ConfigurationError, "Computed projection exceeded its cooperative budget" if @computed && monotonic > @deadline
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Fields
5
+ class Registry
6
+ Definition = Data.define(:key, :handler_name) do
7
+ def handler_class
8
+ klass = handler_name.constantize
9
+ unless klass.is_a?(Class) && klass < Type && klass.name
10
+ raise ConfigurationError, "Registered field type must inherit RailsAiAdmin::Fields::Type"
11
+ end
12
+ klass
13
+ rescue NameError
14
+ raise ConfigurationError, "Registered field type could not be loaded", cause: nil
15
+ end
16
+
17
+ def metadata = handler_class.metadata
18
+ end
19
+
20
+ def initialize(types)
21
+ unless types.is_a?(Hash) && types.all? { |key, name| (key.is_a?(String) || key.is_a?(Symbol)) && key.to_s.match?(/\A[a-z][a-z0-9_]{0,39}\z/) && name.is_a?(String) && name.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/) }
22
+ raise ConfigurationError, "field_types must map stable keys to class-name strings"
23
+ end
24
+ @mapping = types.transform_keys(&:to_s).transform_values(&:dup)
25
+ if @mapping.size != types.size || (@mapping.keys & Field::TYPES.map(&:to_s)).any?
26
+ raise ConfigurationError, "Field registrations cannot duplicate or replace built-in types"
27
+ end
28
+ @mapping.each { |key, name| key.freeze; name.freeze }
29
+ @mapping.freeze
30
+ @entries = @mapping.to_h { |key, name| [ key, Definition.new(key, name) ] }.freeze
31
+ freeze
32
+ end
33
+
34
+ def matches?(types) = types.is_a?(Hash) && types.transform_keys(&:to_s) == @mapping
35
+ def definitions = @entries.values
36
+ def validate!
37
+ definitions.each(&:metadata)
38
+ self
39
+ end
40
+
41
+ def build(name, type:, source: nil, **options)
42
+ return Field.new(name, type: type, source: source, **options) if Field::TYPES.include?(type.to_sym)
43
+ definition = @entries.fetch(type.to_s) { raise ConfigurationError, "Unregistered field type" }
44
+ Field.new(name, type: type, plugin: definition, source: source, **options)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json_schemer"
4
+
5
+ module RailsAiAdmin
6
+ module Fields
7
+ # A deliberately scalar-only subset of the released Surface schema contract.
8
+ # Core constraints stay outside the extension in an intersection.
9
+ module ScalarSchema
10
+ SHARED_KEYS = %w[type enum const title description].freeze
11
+ TYPE_KEYS = { "string" => %w[pattern minLength maxLength], "integer" => %w[minimum maximum], "boolean" => [] }.freeze
12
+ def self.scalar?(value)
13
+ value.nil? || value == true || value == false || value.is_a?(String) && value.valid_encoding? ||
14
+ value.is_a?(Integer) || value.is_a?(Float) && value.finite?
15
+ end
16
+ private_class_method :scalar?
17
+
18
+ def self.compile(base, constraints)
19
+ unless constraints.is_a?(Hash) && constraints.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) } && constraints.stringify_keys.size == constraints.size
20
+ raise ConfigurationError, "Field schema constraints must be a scalar keyword hash"
21
+ end
22
+ constraints = constraints.stringify_keys
23
+ allowed_keys = SHARED_KEYS + TYPE_KEYS.fetch(base.fetch("type"))
24
+ if (constraints.keys - allowed_keys).any? || constraints.key?("type") && constraints["type"] != base.fetch("type")
25
+ raise ConfigurationError, "Field schema cannot change the core scalar type or use unsupported keywords"
26
+ end
27
+ if constraints["pattern"] && (!constraints["pattern"].is_a?(String) || constraints["pattern"].bytesize > 1000)
28
+ raise ConfigurationError, "Field schema pattern must be bounded text"
29
+ end
30
+ %w[title description].each do |key|
31
+ raise ConfigurationError, "Field schema text must be bounded" if constraints[key] && (!constraints[key].is_a?(String) || constraints[key].bytesize > 300)
32
+ end
33
+ if constraints.key?("const") && !scalar?(constraints["const"]) || constraints.key?("enum") &&
34
+ (!constraints["enum"].is_a?(Array) || !constraints["enum"].all? { |value| scalar?(value) })
35
+ raise ConfigurationError, "Field enum and const constraints must contain JSON scalars"
36
+ end
37
+ encoded = JSON.generate(constraints)
38
+ raise ConfigurationError, "Field schema is too large" if encoded.bytesize > 8192
39
+ constraints = JSON.parse(encoded)
40
+ schema = constraints.empty? ? base : base.merge("allOf" => [ constraints ])
41
+ validator = JSONSchemer.schema(schema, ref_resolver: ->(*) { raise ConfigurationError, "Field schema references are unsupported" })
42
+ raise ConfigurationError, "Invalid scalar field schema" unless validator.valid_schema?
43
+ [ schema, validator ]
44
+ rescue JSON::GeneratorError, JSON::ParserError, EncodingError, RegexpError, ArgumentError
45
+ raise ConfigurationError, "Invalid scalar field schema", cause: nil
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Fields
5
+ # Pure refinements of an explicitly granted core scalar. Never an authority
6
+ # or record lookup API. Each invocation gets a new, stateless instance.
7
+ class Type
8
+ extend HookSignatures
9
+ class << self
10
+ def scalar(value = nil)
11
+ @scalar = value.to_sym if value
12
+ @scalar
13
+ end
14
+
15
+ def version(value = nil)
16
+ @version = value if value
17
+ @version || "1"
18
+ end
19
+
20
+ %i[form_partial index_partial show_partial].each do |method|
21
+ define_method(method) do |value = nil|
22
+ instance_variable_set("@#{method}", value) if value
23
+ instance_variable_get("@#{method}")
24
+ end
25
+ end
26
+
27
+ def metadata
28
+ unless scalar && Field::TYPES.include?(scalar) && scalar != :belongs_to && version.is_a?(String) && version.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}\z/)
29
+ raise ConfigurationError, "Field type needs a built-in scalar and bounded version"
30
+ end
31
+ partials = %i[form_partial index_partial show_partial].to_h { |key| [ key, public_send(key) ] }
32
+ unless partials.values.all? { |path| path.nil? || path.is_a?(String) && path.length <= 200 && path.match?(/\A[a-z][a-z0-9_]*(?:\/[a-z][a-z0-9_]*)*\z/) }
33
+ raise ConfigurationError, "Field partials must be trusted relative Rails view paths"
34
+ end
35
+ methods = { normalize: [ 1, %i[field time_zone] ], valid?: [ 1, %i[field time_zone] ], format: [ 1, %i[field time_zone] ], schema: [ 0, [ :field ] ] }
36
+ unless respond_to?(:new) && accepts?(method(:new), 0, []) && accepts?(instance_method(:initialize), 0, []) &&
37
+ methods.all? { |key, (count, keywords)| public_instance_methods.include?(key) && accepts?(instance_method(key), count, keywords) }
38
+ raise ConfigurationError, "Field type has an incompatible public constructor or hook signature"
39
+ end
40
+ { handler: name, scalar: scalar, version: version, **partials }
41
+ end
42
+ end
43
+
44
+ def normalize(value, field:, time_zone:) = value
45
+ def valid?(value, field:, time_zone:) = true
46
+ def format(text, field:, time_zone:) = text
47
+ def schema(field:) = {}
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Forms
5
+ # Presentation-only snapshots; callers supply currently authorized fields.
6
+ # No record, denied value, query or persistence lives in this plan.
7
+ class Plan
8
+ Entry = Data.define(:field, :editable, :value)
9
+ Panel = Data.define(:label, :entries)
10
+ Tab = Data.define(:label, :panels)
11
+ MAX_FIELDS = 128
12
+ MAX_TABS = 8
13
+ MAX_PANELS = 16
14
+ MAX_VALUE_BYTES = 262_144
15
+ MAX_BYTES = 1_048_576
16
+ attr_reader :tabs, :entries
17
+
18
+ def initialize(fields:, values:, readonly_fields: [], readonly_values: {}, order: nil)
19
+ unless values.is_a?(Hash) && readonly_values.is_a?(Hash)
20
+ raise ConfigurationError, "Form values must be scalar maps"
21
+ end
22
+ writable = fields.reject(&:hidden?).index_by(&:name)
23
+ readable = readonly_fields.reject(&:hidden?).index_by(&:name).except(*writable.keys)
24
+ @bytes = 0
25
+ @entries = (Array(order) + fields + readonly_fields).uniq(&:name).filter_map do |field|
26
+ next unless writable.key?(field.name) || readable.key?(field.name)
27
+ editable = writable.key?(field.name)
28
+ source = editable ? values : readonly_values
29
+ value = source.key?(field.name.to_s) ? source[field.name.to_s] : source[field.name]
30
+ Entry.new(field, editable, scalar(value, type: field.scalar_type))
31
+ end.freeze
32
+ raise ConfigurationError, "Form field bound exceeded" if @entries.size > MAX_FIELDS
33
+ @tabbed = @entries.any? { |entry| entry.field.options[:tab] }
34
+ groups = @entries.group_by { |entry| entry.field.options[:tab] || (@tabbed ? "Details" : nil) }
35
+ raise ConfigurationError, "Form tab bound exceeded" if groups.size > MAX_TABS
36
+ @tabs = groups.map do |label, entries|
37
+ panels = entries.group_by { |entry| entry.field.options[:panel] }
38
+ raise ConfigurationError, "Form panel bound exceeded" if panels.size > MAX_PANELS
39
+ Tab.new(label, panels.map { |title, items| Panel.new(title, items.freeze) }.freeze)
40
+ end.freeze
41
+ @tabbed &&= @tabs.size > 1
42
+ freeze
43
+ end
44
+
45
+ def tabbed? = @tabbed
46
+
47
+ private
48
+
49
+ def scalar(value, type: nil)
50
+ copy = case value
51
+ when nil, true, false, Integer then value
52
+ when String then String.new(value).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
53
+ when BigDecimal
54
+ raise ConfigurationError, "Form decimals must be finite" unless value.finite?
55
+ value
56
+ when Time, ActiveSupport::TimeWithZone, DateTime then value.to_time.getutc
57
+ when Date then Date.iso8601(value.iso8601)
58
+ when Fields::FileValue then value
59
+ # A references field carries its target keys.
60
+ when Array
61
+ if type == :files
62
+ raise ConfigurationError, "Form files must be file values" unless value.all? { |item| item.is_a?(Fields::FileValue) }
63
+ value.dup.freeze
64
+ elsif type == :references
65
+ raise ConfigurationError, "Form references must be key text" unless value.all? { |item| item.is_a?(String) }
66
+ value.map { |item| String.new(item).freeze }.freeze
67
+ else
68
+ raise ConfigurationError, "Form values require supported scalars" unless type == :json
69
+ json_text(value)
70
+ end
71
+ # A JSON field edits its document as indented text.
72
+ when Hash, Float
73
+ raise ConfigurationError, "Form values require supported scalars" unless type == :json
74
+ json_text(value)
75
+ else raise ConfigurationError, "Form values require supported scalars"
76
+ end
77
+ bytes = JSON.generate(InputSchema.canonical(copy)).bytesize
78
+ @bytes += bytes
79
+ raise ConfigurationError, "Form value bound exceeded" if bytes > MAX_VALUE_BYTES || @bytes > MAX_BYTES
80
+ copy.freeze
81
+ end
82
+
83
+ def json_text(value)
84
+ JSON.pretty_generate(value)
85
+ rescue JSON::GeneratorError
86
+ raise ConfigurationError, "Form JSON values must be finite", cause: nil
87
+ end
88
+ end
89
+ end
90
+ end