spree_api 6.0.0.rc3 → 6.0.0.rc4

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/spree/api/v3/admin/email_template_lookup.rb +6 -1
  3. data/app/controllers/concerns/spree/api/v3/admin/reporting_authorization.rb +17 -8
  4. data/app/controllers/concerns/spree/api/v3/admin/store_context.rb +7 -0
  5. data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +61 -0
  6. data/app/controllers/concerns/spree/api/v3/error_handler.rb +49 -0
  7. data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +76 -7
  8. data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +60 -3
  9. data/app/controllers/spree/api/v3/admin/companies/tax_exemption_certificates_controller.rb +11 -5
  10. data/app/controllers/spree/api/v3/admin/concerns/external_references.rb +3 -0
  11. data/app/controllers/spree/api/v3/admin/exports_controller.rb +15 -3
  12. data/app/controllers/spree/api/v3/admin/imports_controller.rb +12 -3
  13. data/app/controllers/spree/api/v3/admin/markets_controller.rb +3 -1
  14. data/app/controllers/spree/api/v3/admin/me_controller.rb +21 -1
  15. data/app/controllers/spree/api/v3/admin/oauth/application_tokens_controller.rb +56 -0
  16. data/app/controllers/spree/api/v3/admin/oauth/applications_controller.rb +55 -0
  17. data/app/controllers/spree/api/v3/admin/oauth/authorizations_controller.rb +294 -0
  18. data/app/controllers/spree/api/v3/admin/order_groups_controller.rb +1 -1
  19. data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +8 -0
  20. data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +1 -0
  21. data/app/controllers/spree/api/v3/admin/orders/returns_controller.rb +7 -0
  22. data/app/controllers/spree/api/v3/admin/orders_controller.rb +9 -2
  23. data/app/controllers/spree/api/v3/admin/purchase_orders_controller.rb +1 -0
  24. data/app/controllers/spree/api/v3/admin/reporting_controller.rb +20 -14
  25. data/app/controllers/spree/api/v3/admin/stock_levels_controller.rb +1 -1
  26. data/app/controllers/spree/api/v3/admin/tags_controller.rb +8 -5
  27. data/app/controllers/spree/api/v3/admin/tax_exemption_reasons_controller.rb +58 -0
  28. data/app/controllers/spree/api/v3/admin/tax_rates_controller.rb +0 -1
  29. data/app/controllers/spree/api/v3/admin/translations/batches_controller.rb +17 -11
  30. data/app/controllers/spree/api/v3/oauth/authorizations_controller.rb +61 -0
  31. data/app/controllers/spree/api/v3/oauth/metadata_controller.rb +57 -0
  32. data/app/controllers/spree/api/v3/oauth/server_metadata_controller.rb +57 -0
  33. data/app/controllers/spree/api/v3/resource_controller.rb +1 -0
  34. data/app/controllers/spree/api/v3/store/carts_controller.rb +4 -5
  35. data/app/request_schemas/spree/api/v3/admin/email_template_preview_request_schema.rb +4 -0
  36. data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -0
  37. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +2 -0
  38. data/app/serializers/spree/api/v3/admin/oauth_access_token_serializer.rb +62 -0
  39. data/app/serializers/spree/api/v3/admin/oauth_application_serializer.rb +56 -0
  40. data/app/serializers/spree/api/v3/admin/oauth_consent_request_serializer.rb +25 -0
  41. data/app/serializers/spree/api/v3/admin/oauth_protected_resource_serializer.rb +27 -0
  42. data/app/serializers/spree/api/v3/admin/oauth_redirect_serializer.rb +17 -0
  43. data/app/serializers/spree/api/v3/admin/payment_capture_event_serializer.rb +16 -0
  44. data/app/serializers/spree/api/v3/admin/payment_serializer.rb +12 -1
  45. data/app/serializers/spree/api/v3/admin/return_serializer.rb +2 -0
  46. data/app/serializers/spree/api/v3/admin/tax_exemption_certificate_serializer.rb +7 -3
  47. data/app/serializers/spree/api/v3/admin/tax_exemption_reason_serializer.rb +20 -0
  48. data/app/serializers/spree/api/v3/admin/tax_rate_serializer.rb +1 -2
  49. data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +4 -1
  50. data/app/serializers/spree/api/v3/base_serializer.rb +14 -0
  51. data/config/routes.rb +61 -1
  52. data/lib/spree/api/agent_membership_map.rb +73 -0
  53. data/lib/spree/api/agent_resource_map.rb +350 -0
  54. data/lib/spree/api/agent_workflow_endpoints.rb +113 -0
  55. data/lib/spree/api/agent_write_schemas.rb +119 -0
  56. data/lib/spree/api/dependencies.rb +4 -3
  57. data/lib/spree/api/engine.rb +40 -0
  58. data/lib/spree/api/extension_fields.rb +167 -0
  59. data/lib/spree/api/oauth.rb +243 -0
  60. data/lib/spree/api.rb +8 -0
  61. data/lib/typelizer/open_enums.rb +17 -3
  62. metadata +26 -6
data/config/routes.rb CHANGED
@@ -238,6 +238,27 @@ Spree::Core::Engine.add_routes do
238
238
  post 'auth/password_resets', to: 'password_resets#create'
239
239
  patch 'auth/password_resets/:id', to: 'password_resets#update'
240
240
 
241
+ # OAuth 2.1 consent, read and decided by the dashboard. The admin's
242
+ # own session authorizes it: a secret key may authenticate here but
243
+ # can never grant authority on a person's behalf.
244
+ namespace :oauth do
245
+ get 'authorize', to: 'authorizations#show'
246
+ post 'authorize', to: 'authorizations#create'
247
+ delete 'authorize', to: 'authorizations#destroy'
248
+
249
+ # The OAuth clients registered against this store. Deleting one
250
+ # removes the registration; revoking a connection is the nested
251
+ # `tokens` resource, because that is what revoking deletes.
252
+ #
253
+ # Tokens are plural and addressable: a store registers a client once
254
+ # and every staff member connects to that same registration, so the
255
+ # live tokens are who is connected. Revoking one has to cut off that
256
+ # person and leave their colleagues working.
257
+ resources :applications, except: :show do
258
+ resources :tokens, only: %i[index destroy], controller: 'application_tokens'
259
+ end
260
+ end
261
+
241
262
  # Semantic reporting (docs/plans/6.0-analytics-semantic-layer.md)
242
263
  namespace :reporting do
243
264
  post :query
@@ -409,11 +430,12 @@ Spree::Core::Engine.add_routes do
409
430
  # preference, so there is nothing here to create.
410
431
  resources :payout_providers, only: [:index]
411
432
 
412
- # Return / claim / refund / cancellation reasons (dropdowns + settings management)
433
+ # Return / claim / refund / cancellation / tax exemption reasons (dropdowns + settings management)
413
434
  resources :return_reasons
414
435
  resources :claim_reasons
415
436
  resources :refund_reasons
416
437
  resources :order_cancellation_reasons
438
+ resources :tax_exemption_reasons
417
439
 
418
440
  # Markets
419
441
  resources :markets
@@ -1052,6 +1074,14 @@ Spree::Core::Engine.add_routes do
1052
1074
  end
1053
1075
  end
1054
1076
 
1077
+ # RFC 9728 Protected Resource Metadata. One document per protected
1078
+ # resource, named by the surface that registered it, so a client
1079
+ # discovers which authorization server guards the URL it was given.
1080
+ # Unauthenticated: a client has no credential at the point it asks.
1081
+ get 'oauth/protected-resource/:resource_key',
1082
+ to: 'oauth/metadata#show',
1083
+ as: :oauth_protected_resource
1084
+
1055
1085
  # Webhooks (outside of store namespace — no API key authentication)
1056
1086
  namespace :webhooks do
1057
1087
  post 'payments/:payment_method_id', to: 'payments#create', as: :payment_webhook
@@ -1066,4 +1096,34 @@ Spree::Core::Engine.add_routes do
1066
1096
  end
1067
1097
  end
1068
1098
  end
1099
+
1100
+ # The authorization server's own endpoints: /oauth/authorize, /oauth/token,
1101
+ # /oauth/revoke and RFC 8414 metadata. Outside the versioned API namespace —
1102
+ # the OAuth protocol is not a Spree API resource and its paths are fixed by
1103
+ # the spec and by what clients expect to find.
1104
+ #
1105
+ # `authorizations` is NOT skipped. Skipping it would leave the metadata
1106
+ # document without an `authorization_endpoint`, which is required for the
1107
+ # code flow, so a consumer client would have nowhere to send the browser.
1108
+ # The controller is Spree's own and redirects to the dashboard's consent
1109
+ # screen instead of rendering Doorkeeper's HTML.
1110
+ #
1111
+ # The application-management controllers stay skipped: connected apps are
1112
+ # managed through /api/v3/admin/oauth/applications, under the Admin API's
1113
+ # own authentication.
1114
+ # `controllers` is a block method, not an option to `use_doorkeeper`.
1115
+ #
1116
+ # Doorkeeper's own controllers need a leading slash because these routes are
1117
+ # drawn inside Spree::Core::Engine, which isolates the namespace — a bare
1118
+ # `doorkeeper/tokens` resolves as `Spree::Doorkeeper::Tokens` and fails on
1119
+ # the first request. Spree's own authorizations controller is named from the
1120
+ # application root WITHOUT a slash, because the metadata document builds its
1121
+ # URL with `url_for(controller: "/#{name}")` and adds the slash itself.
1122
+ use_doorkeeper scope: 'oauth' do
1123
+ controllers authorizations: 'api/v3/oauth/authorizations',
1124
+ tokens: '/doorkeeper/tokens',
1125
+ token_info: '/doorkeeper/token_info',
1126
+ metadata: '/spree/api/v3/oauth/server_metadata'
1127
+ skip_controllers :applications, :authorized_applications
1128
+ end
1069
1129
  end
@@ -0,0 +1,73 @@
1
+ module Spree
2
+ module Api
3
+ # Which parents an agent may curate products into, derived from the
4
+ # controllers that adopt `Spree::Api::V3::Admin::ProductMembership`.
5
+ #
6
+ # Read off the controllers for the same reason {AgentResourceMap} is: a
7
+ # parent that adopts the concern later is curated without anyone editing
8
+ # this file, and one that drops it stops being offered.
9
+ module AgentMembershipMap
10
+ class << self
11
+ # @param map [Module]
12
+ # @return [Integer] how many parents were registered
13
+ def install(map: Spree::AgentTools::MembershipMap)
14
+ map.reset!
15
+
16
+ curating_controllers.each do |controller|
17
+ target = target_for(controller)
18
+ next if target.blank?
19
+
20
+ # Addressing a parent an agent cannot otherwise read would hand it
21
+ # a write with no matching read, and no way to name what it changed.
22
+ entry = Spree::AgentTools::ResourceMap.find(target.pluralize)
23
+ next if entry.nil?
24
+
25
+ map.register(
26
+ target: target,
27
+ permission: write_permission_for(entry),
28
+ api_path: membership_path_for(controller)
29
+ )
30
+ end
31
+
32
+ map.all.size
33
+ end
34
+
35
+ private
36
+
37
+ # Where this parent's products are added and removed, read from the
38
+ # routes. The nested path carries the parent's id, which a tool fills
39
+ # in from the parent it was given.
40
+ def membership_path_for(controller)
41
+ route = Spree::Core::Engine.routes.routes.find do |candidate|
42
+ candidate.defaults[:controller] == controller.controller_path &&
43
+ candidate.defaults[:action] == 'create'
44
+ end
45
+
46
+ route&.path&.spec.to_s.sub('(.:format)', '').presence
47
+ end
48
+
49
+ def curating_controllers
50
+ Rails.application.eager_load! unless Rails.application.config.eager_load
51
+
52
+ Spree::Api::V3::Admin::ResourceController.descendants.
53
+ select { |controller| controller.include?(Spree::Api::V3::Admin::ProductMembership) }.
54
+ sort_by(&:name)
55
+ end
56
+
57
+ # Curating is a write to the parent, so it is gated on the parent's
58
+ # own write scope — a price list rides `write_products`, a category
59
+ # `write_categories`, exactly as their controllers do.
60
+ def write_permission_for(entry)
61
+ entry.write_permission.presence || "write_#{entry.permission.to_s.sub(/\Aread_/, '')}"
62
+ end
63
+
64
+ # `Admin::Categories::ProductsController` curates a category. The
65
+ # parent is the namespace the nested controller sits in, which is how
66
+ # the routes name it too.
67
+ def target_for(controller)
68
+ controller.name.to_s.split('::')[-2]&.underscore&.singularize
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,350 @@
1
+ module Spree
2
+ module Api
3
+ # Fills {Spree::AgentTools::ResourceMap} from the Admin API's own
4
+ # controllers.
5
+ #
6
+ # Nothing here is a list. Every admin resource controller already declares
7
+ # the model it serves, the serializer it renders, the permission scope it
8
+ # rides and the workflow it writes through — so the map an agent reads is
9
+ # the API surface by construction, and a resource cannot drift from the
10
+ # endpoint that serves it. A controller that gains an attribute gains it
11
+ # for agents in the same commit.
12
+ #
13
+ # The one fact a controller does not carry is where the record lives in the
14
+ # dashboard, which is only used to link to it, so those are kept here.
15
+ #
16
+ # See docs/plans/6.0-mcp-server.md.
17
+ module AgentResourceMap
18
+ # Dashboard routes, for results that link to the record rather than
19
+ # describing where to find it. Store-relative: the panel prefixes the
20
+ # current store segment.
21
+ DASHBOARD_PATHS = {
22
+ 'products' => '/products/%<id>s',
23
+ 'categories' => '/products/categories/%<id>s',
24
+ 'collections' => '/products/collections/%<id>s',
25
+ 'price_lists' => '/products/price-lists/%<id>s',
26
+ 'orders' => '/orders/%<id>s',
27
+ 'customers' => '/customers/%<id>s',
28
+ 'companies' => '/companies/%<id>s',
29
+ 'sellers' => '/sellers/%<id>s',
30
+ 'promotions' => '/promotions/%<id>s',
31
+ 'channels' => '/settings/channels',
32
+ 'markets' => '/settings/markets',
33
+ 'payment_methods' => '/settings/payment-methods',
34
+ 'delivery_profiles' => '/settings/delivery-profiles',
35
+ 'stock_locations' => '/settings/stock-locations',
36
+ 'tax_categories' => '/settings/tax-categories',
37
+ 'tax_rates' => '/settings/tax-rates',
38
+ 'product_types' => '/settings/product-types',
39
+ 'commission_rates' => '/settings/commission-rates',
40
+ 'roles' => '/settings/roles',
41
+ 'return_reasons' => '/settings/reasons',
42
+ 'allowed_origins' => '/settings/allowed-origins',
43
+ 'imports' => '/settings/imports'
44
+ }.freeze
45
+
46
+ # Resources withheld whatever their controller says, because their
47
+ # serializer carries a live credential or a bearer instrument. Every
48
+ # tool result reaches a model and, for a hosted client, a third party.
49
+ #
50
+ # api_keys, integrations, webhook_endpoints — plaintext tokens,
51
+ # provider credentials and the secret that signs deliveries.
52
+ # gift_cards — the code IS the instrument: whoever reads it can spend
53
+ # the balance. A balance tool that never emits a code could be added.
54
+ # invitations — the acceptance link is a bearer token that mints a
55
+ # staff account at the invited role, and the endpoint
56
+ # it addresses is deliberately unauthenticated. The
57
+ # outbound filter drops the link, but a resource whose
58
+ # whole purpose is handing out authority does not
59
+ # belong on a read surface an agent drives.
60
+ WITHHELD_MODELS = %w[
61
+ Spree::ApiKey
62
+ Spree::OauthApplication
63
+ Spree::Integration
64
+ Spree::WebhookEndpoint
65
+ Spree::WebhookDelivery
66
+ Spree::GiftCard
67
+ Spree::Invitation
68
+ ].freeze
69
+
70
+ # Controllers that resolve their scope per request rather than declaring
71
+ # one, because the scope belongs to the resource being imported or
72
+ # exported — creating a product import is a product write, and reading
73
+ # its rows exposes the uploaded data. A single static key cannot say
74
+ # that, so these are registered read-only under the broadest key the
75
+ # controller can demand, and the write path stays with the endpoint.
76
+ DYNAMIC_SCOPE_RESOURCES = {
77
+ 'imports' => { model_name: 'Spree::Import', permission: 'read_settings',
78
+ serializer_name: 'Spree::Api::V3::Admin::ImportSerializer',
79
+ dashboard_path: '/settings/imports' },
80
+ 'exports' => { model_name: 'Spree::Export', permission: 'read_settings',
81
+ serializer_name: 'Spree::Api::V3::Admin::ExportSerializer' }
82
+ }.freeze
83
+
84
+ class << self
85
+ # Registers this walk as how the core map fills itself, and marks the
86
+ # map stale so the next reader gets a fresh one. Called on every code
87
+ # reload; the walk itself runs when something actually reads the map.
88
+ #
89
+ # @return [void]
90
+ def stale!
91
+ Spree::AgentTools::ResourceMap.deriver = -> { install }
92
+ Spree::AgentTools::ResourceMap.stale!
93
+ end
94
+
95
+ # Walks every admin resource controller and registers what it serves.
96
+ #
97
+ # @param map [Class] defaults to the core resource map
98
+ # @return [Integer] how many resources were registered
99
+ def install(map: Spree::AgentTools::ResourceMap)
100
+ map.reset!
101
+
102
+ registered = resolve_collisions(controllers.filter_map { |controller| derive(controller) })
103
+ registered.each { |entry| map.register(**entry) }
104
+
105
+ DYNAMIC_SCOPE_RESOURCES.each { |key, attributes| map.register(key: key, **attributes) }
106
+
107
+ registered.size + DYNAMIC_SCOPE_RESOURCES.size
108
+ end
109
+
110
+ # Every admin resource controller in the application, the abstract
111
+ # bases excluded. Eager-loaded first: in development only the
112
+ # controllers already used would otherwise be known, and the map would
113
+ # differ between a fresh boot and a warm one.
114
+ #
115
+ # @return [Array<Class>]
116
+ def controllers
117
+ Rails.application.eager_load! unless Rails.application.config.eager_load
118
+
119
+ Spree::Api::V3::Admin::ResourceController.descendants.reject(&:abstract?).sort_by(&:name)
120
+ end
121
+
122
+ # What one controller contributes, or nil when it serves nothing an
123
+ # agent can address.
124
+ #
125
+ # @param controller [Class]
126
+ # @return [Hash, nil]
127
+ # Where this controller's operations live, read from the routes rather
128
+ # than guessed from the key: a nested or renamed resource keeps its
129
+ # real path, and a controller with no route is not reachable at all.
130
+ #
131
+ # The shallowest route wins. A resource reached both at the top level
132
+ # and under a parent (`/products` and
133
+ # `/catalogs/:catalog_id/products`) is addressed by the former: a tool
134
+ # given a product id has no parent id to fill in, and the nested route
135
+ # would leave `:catalog_id` in the path.
136
+ #
137
+ # @return [Hash{Symbol => String}] action => path template
138
+ # Across colliding controllers, the shallowest path per action.
139
+ def shallowest_paths(candidates)
140
+ candidates.flat_map { |entry| Array(entry[:api_paths]).to_a }.
141
+ group_by(&:first).
142
+ transform_values { |pairs| pairs.map(&:last).min_by { |path| [path.count(':'), path.length] } }
143
+ end
144
+
145
+ def api_paths_for(controller)
146
+ candidates = Spree::Core::Engine.routes.routes.filter_map do |route|
147
+ next unless route.defaults[:controller] == controller.controller_path
148
+
149
+ action = route.defaults[:action]&.to_sym
150
+ next if action.nil?
151
+
152
+ [action, route.path.spec.to_s.sub('(.:format)', '')]
153
+ end
154
+
155
+ candidates.group_by(&:first).transform_values do |pairs|
156
+ pairs.map(&:last).min_by { |path| [path.count(':'), path.length] }
157
+ end
158
+ end
159
+
160
+ def derive(controller)
161
+ instance = controller.allocate
162
+ model = safely(instance, :model_class)
163
+ return if model.nil? || WITHHELD_MODELS.include?(model.name)
164
+
165
+ scope = controller._scoped_resource
166
+ return if scope.blank?
167
+
168
+ serializer = safely(instance, :serializer_class)
169
+ return if serializer.nil?
170
+
171
+ key = key_for(model)
172
+ return unless store_scoped?(model)
173
+
174
+ create_workflow = workflow_key(instance, :create_workflow)
175
+ update_workflow = workflow_key(instance, :update_workflow)
176
+ writable = AgentWriteSchemas.attribute_names(key)
177
+
178
+ {
179
+ key: key,
180
+ model_name: model.name,
181
+ permission: "read_#{scope}",
182
+ serializer_name: serializer.name,
183
+ dashboard_path: DASHBOARD_PATHS[key],
184
+ api_paths: api_paths_for(controller),
185
+ write_permission: write_permission_for(model, scope, create_workflow, update_workflow, writable, controller),
186
+ writable_attributes: writable,
187
+ create_workflow_key: create_workflow,
188
+ update_workflow_key: update_workflow,
189
+ distinct: collection_distinct?(instance)
190
+ }
191
+ end
192
+
193
+ private
194
+
195
+ # What the controller does with its own collection. A controller that
196
+ # disables DISTINCT has a reason — Postgres refuses it beside an
197
+ # ORDER BY the select list does not carry — and a search tool adding
198
+ # it anyway answers with a 500.
199
+ def collection_distinct?(instance)
200
+ return true unless instance.respond_to?(:collection_distinct?, true)
201
+
202
+ instance.send(:collection_distinct?)
203
+ rescue StandardError
204
+ true
205
+ end
206
+
207
+ # A model can be served by more than one controller under different
208
+ # permission scopes — a tax identifier through customers and through
209
+ # orders, media through products and through the media library. One
210
+ # resource key can carry only one permission, and whichever controller
211
+ # happened to sort last is not an answer: it would grant a key holding
212
+ # `read_orders` the customers' records too.
213
+ #
214
+ # So a collision resolves to the narrowest scope offered, and the
215
+ # resource stays read-only, because the two controllers do not agree
216
+ # on who may write it. A caller holding the wider scope reaches the
217
+ # record through the endpoint, as before.
218
+ def resolve_collisions(entries)
219
+ entries.group_by { |entry| entry[:key] }.map do |_key, candidates|
220
+ next candidates.first if candidates.one?
221
+
222
+ narrowest = candidates.min_by { |entry| [scope_breadth(entry[:permission]), entry[:permission]] }
223
+ contested = candidates.map { |entry| entry[:permission] }.uniq.size > 1
224
+
225
+ # A workflow declaration is not in dispute — whichever controller
226
+ # declares one, that is how the resource is written, and the
227
+ # refusal has to keep naming that tool. Only the generic write is
228
+ # withdrawn, and only when the controllers disagree about the
229
+ # scope guarding it.
230
+ narrowest.merge(
231
+ create_workflow_key: candidates.filter_map { |entry| entry[:create_workflow_key] }.first,
232
+ update_workflow_key: candidates.filter_map { |entry| entry[:update_workflow_key] }.first,
233
+ write_permission: contested ? nil : narrowest[:write_permission],
234
+ writable_attributes: contested ? [] : narrowest[:writable_attributes],
235
+ # One controller refusing DISTINCT is enough: its sort would
236
+ # make Postgres reject the query, and whichever controller
237
+ # happened to sort first is not an answer.
238
+ distinct: candidates.all? { |entry| entry[:distinct] },
239
+ # The shallowest route across every controller claiming this
240
+ # key. `narrowest` is chosen by permission breadth, which says
241
+ # nothing about depth — so a resource served both at the top
242
+ # level and under a parent kept whichever won on scope, and a
243
+ # tool was handed a path with an unfillable `:parent_id` in it.
244
+ api_paths: shallowest_paths(candidates)
245
+ )
246
+ end
247
+ end
248
+
249
+ # How much a scope covers, so the narrowest wins a collision. Measured
250
+ # by the number of resources the catalog grants it, which is the only
251
+ # ranking the catalog actually gives us.
252
+ def scope_breadth(permission)
253
+ _kind, scope = Spree.permissions.resolve_key(permission)
254
+ scope ? Array(scope.resources).size : Float::INFINITY
255
+ rescue StandardError
256
+ Float::INFINITY
257
+ end
258
+
259
+ # A resource is generically writable only when the Admin API writes it
260
+ # by saving the record, and the OpenAPI document says what a write may
261
+ # set. A workflow-written resource keeps its workflow key instead (so
262
+ # the refusal can name the tool); a service-written one is not writable
263
+ # at all; and a resource with no documented body is read-only here,
264
+ # because a write tool with no attributes is one an agent can only get
265
+ # wrong.
266
+ # Writable when the controller documents a body and does not hand out
267
+ # authority.
268
+ #
269
+ # The list of service-written models this used to consult is gone
270
+ # (2026-10-10). It existed because a generic write assigned
271
+ # attributes and saved, so reaching a record whose controller
272
+ # orchestrates — an order, a refund, a stock level — would have
273
+ # skipped that orchestration. A dispatched write IS the controller,
274
+ # so there is nothing left to skip, and the list only withheld writes
275
+ # the API performs correctly.
276
+ #
277
+ # A declared workflow no longer withholds it either: the controller
278
+ # runs its own workflow, which is what the generic write now reaches.
279
+ def write_permission_for(model, scope, create_workflow, update_workflow, writable, controller)
280
+ return if writable.empty?
281
+ return if hands_out_authority?(controller)
282
+
283
+ "write_#{scope}"
284
+ end
285
+
286
+ # A controller that hands out authority — roles, role grants,
287
+ # invitations, API keys — runs an anti-amplification check the generic
288
+ # write knows nothing about: a caller may only grant permissions they
289
+ # themselves hold. Assigning those attributes and calling `save` would
290
+ # skip it, so writing them stays with the endpoint that guards them.
291
+ #
292
+ # Asked of the controller rather than listed, so a surface that adopts
293
+ # the guard later is covered without anyone remembering this file.
294
+ def hands_out_authority?(controller)
295
+ return false unless defined?(Spree::Api::V3::Admin::RoleGrantGuard)
296
+
297
+ controller.include?(Spree::Api::V3::Admin::RoleGrantGuard)
298
+ rescue StandardError
299
+ false
300
+ end
301
+
302
+ # The plural the resource is addressed by, taken from the model so two
303
+ # controllers serving the same model (a nested one and a top-level one)
304
+ # agree on it.
305
+ def key_for(model)
306
+ model.model_name.element.pluralize
307
+ end
308
+
309
+ # `Spree::Base.for_store` falls back to the bare class for a model with
310
+ # no Store association, which would hand this store's agent every
311
+ # store's rows. A model that defines its own `for_store` has made a
312
+ # deliberate choice (customers are global by design and say so);
313
+ # inheriting the fallback is an accident, and a missing resource is
314
+ # better than a leaking one.
315
+ def store_scoped?(model)
316
+ return false unless model.respond_to?(:for_store)
317
+ return true if model.method(:for_store).owner != Spree::Base.singleton_class
318
+
319
+ !model.for_store(probe_store).equal?(model)
320
+ rescue StandardError
321
+ false
322
+ end
323
+
324
+ # One unsaved store, reused: the probe asks what `for_store` does with
325
+ # a store, not with any particular one, and the walk covers every
326
+ # admin controller.
327
+ def probe_store
328
+ @probe_store ||= Spree::Store.new
329
+ end
330
+
331
+ # The workflow a controller writes through, as its dotted key, so a
332
+ # generic write can name the tool that does the job instead.
333
+ def workflow_key(instance, method_name)
334
+ workflow = safely(instance, method_name)
335
+ workflow.respond_to?(:workflow_key) ? workflow.workflow_key : nil
336
+ end
337
+
338
+ # A controller may raise from any of these — `model_class` is
339
+ # NotImplementedError on an abstract one, `permitted_attributes` reads
340
+ # request state on a few — and one such controller must not take the
341
+ # whole map down at boot.
342
+ def safely(instance, method_name)
343
+ instance.send(method_name)
344
+ rescue StandardError, NotImplementedError
345
+ nil
346
+ end
347
+ end
348
+ end
349
+ end
350
+ end
@@ -0,0 +1,113 @@
1
+ module Spree
2
+ module Api
3
+ # Which Admin API operation runs each workflow, derived rather than listed.
4
+ #
5
+ # Walk the admin routes; for each one, read the controller source and ask
6
+ # whether the action behind it calls that workflow. The verb and path come
7
+ # from the route, and the body keys from the generated OpenAPI document —
8
+ # which is the only place that records a rename (a cancellation's
9
+ # `reason:` keyword travels as `cancel_reason_id`).
10
+ #
11
+ # Read off the controllers for the same reason {AgentResourceMap} is: a
12
+ # workflow that gains an endpoint is dispatched without anyone editing a
13
+ # file, and one whose endpoint goes away stops being dispatched.
14
+ module AgentWorkflowEndpoints
15
+ # Where a controller declares a workflow for the base class to run,
16
+ # rather than calling it inside an action of its own.
17
+ DECLARED_IN = { 'create_workflow' => 'create', 'update_workflow' => 'update' }.freeze
18
+
19
+ class << self
20
+ # @param map [Module]
21
+ # @return [Integer] how many workflows were mapped
22
+ def install(map: Spree::AgentTools::WorkflowEndpoints)
23
+ map.reset!
24
+ keys = Spree::AgentTools::DefaultCatalog::WORKFLOWS.keys
25
+ document = openapi_document
26
+
27
+ admin_routes.each do |route|
28
+ source = controller_source(route[:controller])
29
+ next if source.nil?
30
+
31
+ keys.each do |key|
32
+ # `find` would derive, and deriving is what this is — read the
33
+ # entries already registered instead.
34
+ next if map.registered.key?(key)
35
+ next unless action_calling(source, key) == route[:action]
36
+
37
+ map.register(key: key, verb: route[:verb], path: route[:path],
38
+ body_keys: body_keys_for(document, route))
39
+ end
40
+ end
41
+
42
+ map.registered.size
43
+ end
44
+
45
+ private
46
+
47
+ def admin_routes
48
+ Spree::Core::Engine.routes.routes.filter_map do |route|
49
+ controller = route.defaults[:controller].to_s
50
+ next unless controller.start_with?('spree/api/v3/admin')
51
+
52
+ { controller: controller, action: route.defaults[:action].to_s,
53
+ verb: route.verb.to_s, path: route.path.spec.to_s.sub('(.:format)', '') }
54
+ end
55
+ end
56
+
57
+ # Which action calls this workflow: the `def` nearest above the call.
58
+ # A declaration consumed by the base class names the action it serves
59
+ # instead.
60
+ def action_calling(source, key)
61
+ index = source.index("Spree.#{key}")
62
+ return if index.nil?
63
+
64
+ enclosing = source[0...index].scan(/^\s+def ([a-z_]+)/).flatten.last
65
+ DECLARED_IN.fetch(enclosing, enclosing)
66
+ end
67
+
68
+ def controller_source(controller_path)
69
+ @sources ||= {}
70
+ return @sources[controller_path] if @sources.key?(controller_path)
71
+
72
+ @sources[controller_path] = begin
73
+ klass = "#{controller_path.camelize}Controller".safe_constantize
74
+ file = klass && Object.const_source_location(klass.name)&.first
75
+ file && File.exist?(file) ? File.read(file) : nil
76
+ rescue StandardError
77
+ nil
78
+ end
79
+ end
80
+
81
+ # What the operation's request body accepts, in the names the wire
82
+ # uses. A workflow keyword and its body key often differ, and the
83
+ # document is where that difference is recorded.
84
+ def body_keys_for(document, route)
85
+ operation = document.dig('paths', openapi_path(route[:path]), route[:verb].downcase)
86
+ schema = operation&.dig('requestBody', 'content', 'application/json', 'schema')
87
+ # Bodies are declared once and published as components, so the
88
+ # operation points at one rather than carrying its properties.
89
+ schema = Spree::Api::AgentWriteSchemas.resolve_schema(schema)
90
+
91
+ (schema&.dig('properties') || {}).keys
92
+ end
93
+
94
+ def openapi_path(path)
95
+ path.gsub(/:([a-z_]+)/) { "{#{Regexp.last_match(1)}}" }
96
+ end
97
+
98
+ # A packaged installation has no docs/ directory, and a body the
99
+ # document does not describe is simply passed through unchecked.
100
+ #
101
+ # `AgentWriteSchemas` already holds this file parsed, so it is read
102
+ # through there rather than a second time: the document is nearly a
103
+ # megabyte, and parsing it twice at boot is a second of nothing.
104
+ def openapi_document
105
+ Spree::Api::AgentWriteSchemas.document
106
+ rescue StandardError => e
107
+ Rails.logger.warn("[Spree] could not read the admin API document: #{e.class}: #{e.message}")
108
+ {}
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end