spree_api 6.0.0.rc3 → 6.0.0.rc5

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 (156) 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/allowed_redirect_url.rb +27 -0
  7. data/app/controllers/concerns/spree/api/v3/cart_resolvable.rb +8 -2
  8. data/app/controllers/concerns/spree/api/v3/error_handler.rb +49 -0
  9. data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +76 -7
  10. data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +66 -5
  11. data/app/controllers/spree/api/v3/admin/companies/addresses_controller.rb +7 -1
  12. data/app/controllers/spree/api/v3/admin/companies/tax_exemption_certificates_controller.rb +11 -5
  13. data/app/controllers/spree/api/v3/admin/concerns/external_references.rb +3 -0
  14. data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +30 -8
  15. data/app/controllers/spree/api/v3/admin/customers/addresses_controller.rb +4 -0
  16. data/app/controllers/spree/api/v3/admin/customers_controller.rb +1 -6
  17. data/app/controllers/spree/api/v3/admin/exports_controller.rb +15 -3
  18. data/app/controllers/spree/api/v3/admin/gift_cards_controller.rb +1 -8
  19. data/app/controllers/spree/api/v3/admin/imports_controller.rb +12 -3
  20. data/app/controllers/spree/api/v3/admin/markets_controller.rb +3 -1
  21. data/app/controllers/spree/api/v3/admin/me_controller.rb +21 -1
  22. data/app/controllers/spree/api/v3/admin/oauth/application_tokens_controller.rb +56 -0
  23. data/app/controllers/spree/api/v3/admin/oauth/applications_controller.rb +55 -0
  24. data/app/controllers/spree/api/v3/admin/oauth/authorizations_controller.rb +294 -0
  25. data/app/controllers/spree/api/v3/admin/order_groups_controller.rb +1 -1
  26. data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +8 -0
  27. data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +1 -0
  28. data/app/controllers/spree/api/v3/admin/orders/returns_controller.rb +7 -0
  29. data/app/controllers/spree/api/v3/admin/orders_controller.rb +20 -7
  30. data/app/controllers/spree/api/v3/admin/package_types_controller.rb +2 -3
  31. data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +3 -4
  32. data/app/controllers/spree/api/v3/admin/products_controller.rb +5 -3
  33. data/app/controllers/spree/api/v3/admin/purchase_orders_controller.rb +1 -0
  34. data/app/controllers/spree/api/v3/admin/reporting_controller.rb +20 -14
  35. data/app/controllers/spree/api/v3/admin/stock_levels_controller.rb +1 -1
  36. data/app/controllers/spree/api/v3/admin/store_controller.rb +1 -1
  37. data/app/controllers/spree/api/v3/admin/tags_controller.rb +8 -5
  38. data/app/controllers/spree/api/v3/admin/tax_exemption_reasons_controller.rb +58 -0
  39. data/app/controllers/spree/api/v3/admin/tax_rates_controller.rb +0 -1
  40. data/app/controllers/spree/api/v3/admin/translations/batches_controller.rb +17 -11
  41. data/app/controllers/spree/api/v3/admin/variants_controller.rb +96 -1
  42. data/app/controllers/spree/api/v3/oauth/authorizations_controller.rb +72 -0
  43. data/app/controllers/spree/api/v3/oauth/metadata_controller.rb +57 -0
  44. data/app/controllers/spree/api/v3/oauth/server_metadata_controller.rb +57 -0
  45. data/app/controllers/spree/api/v3/resource_controller.rb +36 -5
  46. data/app/controllers/spree/api/v3/seller/package_types_controller.rb +2 -2
  47. data/app/controllers/spree/api/v3/seller/products_controller.rb +7 -61
  48. data/app/controllers/spree/api/v3/seller/resource_controller.rb +2 -6
  49. data/app/controllers/spree/api/v3/store/carts/addresses_controller.rb +53 -0
  50. data/app/controllers/spree/api/v3/store/carts_controller.rb +7 -8
  51. data/app/controllers/spree/api/v3/store/companies/addresses_controller.rb +4 -0
  52. data/app/controllers/spree/api/v3/store/customer/addresses_controller.rb +4 -0
  53. data/app/controllers/spree/api/v3/store/customer/data_requests_controller.rb +19 -1
  54. data/app/controllers/spree/api/v3/store/customer/password_resets_controller.rb +3 -14
  55. data/app/controllers/spree/api/v3/store/data_request_confirmations_controller.rb +23 -0
  56. data/app/controllers/spree/api/v3/store/data_requests_controller.rb +56 -0
  57. data/app/controllers/spree/api/v3/store/newsletter_subscribers_controller.rb +2 -11
  58. data/app/request_schemas/spree/api/v3/admin/email_template_preview_request_schema.rb +4 -0
  59. data/app/request_schemas/spree/api/v3/admin/variant_request_schema.rb +21 -0
  60. data/app/request_schemas/spree/api/v3/seller/price_request_schema.rb +20 -0
  61. data/app/request_schemas/spree/api/v3/seller/variant_request_schema.rb +19 -0
  62. data/app/request_schemas/spree/api/v3/variant_request_schema.rb +8 -13
  63. data/app/serializers/spree/api/v3/address_owner_serializer.rb +27 -0
  64. data/app/serializers/spree/api/v3/address_serializer.rb +5 -1
  65. data/app/serializers/spree/api/v3/admin/address_owner_serializer.rb +10 -0
  66. data/app/serializers/spree/api/v3/admin/address_serializer.rb +1 -10
  67. data/app/serializers/spree/api/v3/admin/api_key_serializer.rb +1 -1
  68. data/app/serializers/spree/api/v3/admin/catalog_assignment_serializer.rb +1 -1
  69. data/app/serializers/spree/api/v3/admin/catalog_price_serializer.rb +1 -1
  70. data/app/serializers/spree/api/v3/admin/collection_rule_serializer.rb +1 -1
  71. data/app/serializers/spree/api/v3/admin/collection_serializer.rb +1 -1
  72. data/app/serializers/spree/api/v3/admin/commission_line_serializer.rb +2 -2
  73. data/app/serializers/spree/api/v3/admin/commission_rate_serializer.rb +1 -1
  74. data/app/serializers/spree/api/v3/admin/consent_record_serializer.rb +1 -1
  75. data/app/serializers/spree/api/v3/admin/email_template_serializer.rb +1 -1
  76. data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -0
  77. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +2 -0
  78. data/app/serializers/spree/api/v3/admin/oauth_access_token_serializer.rb +62 -0
  79. data/app/serializers/spree/api/v3/admin/oauth_application_serializer.rb +56 -0
  80. data/app/serializers/spree/api/v3/admin/oauth_consent_request_serializer.rb +25 -0
  81. data/app/serializers/spree/api/v3/admin/oauth_protected_resource_serializer.rb +27 -0
  82. data/app/serializers/spree/api/v3/admin/oauth_redirect_serializer.rb +17 -0
  83. data/app/serializers/spree/api/v3/admin/order_serializer.rb +1 -1
  84. data/app/serializers/spree/api/v3/admin/package_type_serializer.rb +3 -3
  85. data/app/serializers/spree/api/v3/admin/payment_capture_event_serializer.rb +16 -0
  86. data/app/serializers/spree/api/v3/admin/payment_serializer.rb +12 -1
  87. data/app/serializers/spree/api/v3/admin/permission_serializer.rb +1 -1
  88. data/app/serializers/spree/api/v3/admin/price_list_serializer.rb +1 -1
  89. data/app/serializers/spree/api/v3/admin/purchase_order_serializer.rb +1 -1
  90. data/app/serializers/spree/api/v3/admin/return_serializer.rb +2 -0
  91. data/app/serializers/spree/api/v3/admin/seller_requirement_status_serializer.rb +1 -1
  92. data/app/serializers/spree/api/v3/admin/seller_serializer.rb +2 -2
  93. data/app/serializers/spree/api/v3/admin/seller_transfer_serializer.rb +1 -1
  94. data/app/serializers/spree/api/v3/admin/shipping_label_serializer.rb +3 -3
  95. data/app/serializers/spree/api/v3/admin/stock_location_serializer.rb +1 -1
  96. data/app/serializers/spree/api/v3/admin/stock_receipt_item_serializer.rb +2 -2
  97. data/app/serializers/spree/api/v3/admin/stock_receipt_serializer.rb +1 -1
  98. data/app/serializers/spree/api/v3/admin/stock_transfer_serializer.rb +1 -1
  99. data/app/serializers/spree/api/v3/admin/store_serializer.rb +1 -0
  100. data/app/serializers/spree/api/v3/admin/tax_exemption_certificate_serializer.rb +7 -3
  101. data/app/serializers/spree/api/v3/admin/tax_exemption_reason_serializer.rb +20 -0
  102. data/app/serializers/spree/api/v3/admin/tax_identifier_serializer.rb +2 -2
  103. data/app/serializers/spree/api/v3/admin/tax_line_serializer.rb +1 -1
  104. data/app/serializers/spree/api/v3/admin/tax_rate_serializer.rb +1 -2
  105. data/app/serializers/spree/api/v3/admin/variant_serializer.rb +1 -1
  106. data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +4 -1
  107. data/app/serializers/spree/api/v3/base_serializer.rb +41 -5
  108. data/app/serializers/spree/api/v3/claim_serializer.rb +1 -1
  109. data/app/serializers/spree/api/v3/collection_serializer.rb +1 -1
  110. data/app/serializers/spree/api/v3/company_invitation_serializer.rb +1 -1
  111. data/app/serializers/spree/api/v3/company_serializer.rb +1 -1
  112. data/app/serializers/spree/api/v3/data_request_confirmation_requested_event_serializer.rb +28 -0
  113. data/app/serializers/spree/api/v3/data_request_event_serializer.rb +1 -1
  114. data/app/serializers/spree/api/v3/data_request_serializer.rb +8 -4
  115. data/app/serializers/spree/api/v3/delivery_serializer.rb +1 -1
  116. data/app/serializers/spree/api/v3/delivery_zone_member_serializer.rb +1 -1
  117. data/app/serializers/spree/api/v3/discount_serializer.rb +1 -1
  118. data/app/serializers/spree/api/v3/file_upload_serializer.rb +1 -1
  119. data/app/serializers/spree/api/v3/fulfillment_serializer.rb +1 -1
  120. data/app/serializers/spree/api/v3/media_event_serializer.rb +1 -1
  121. data/app/serializers/spree/api/v3/media_serializer.rb +1 -1
  122. data/app/serializers/spree/api/v3/option_type_serializer.rb +1 -1
  123. data/app/serializers/spree/api/v3/order_group_serializer.rb +2 -2
  124. data/app/serializers/spree/api/v3/order_serializer.rb +4 -2
  125. data/app/serializers/spree/api/v3/payment_serializer.rb +1 -1
  126. data/app/serializers/spree/api/v3/product_filter_availability_option_serializer.rb +1 -1
  127. data/app/serializers/spree/api/v3/product_filter_option_serializer.rb +1 -1
  128. data/app/serializers/spree/api/v3/seller/address_serializer.rb +3 -2
  129. data/app/serializers/spree/api/v3/seller/order_serializer.rb +3 -3
  130. data/app/serializers/spree/api/v3/seller/package_type_serializer.rb +3 -3
  131. data/app/serializers/spree/api/v3/seller/profile_serializer.rb +2 -2
  132. data/app/serializers/spree/api/v3/seller/requirement_status_serializer.rb +1 -1
  133. data/app/serializers/spree/api/v3/seller/tax_identifier_serializer.rb +1 -1
  134. data/app/serializers/spree/api/v3/seller/variant_serializer.rb +1 -1
  135. data/app/serializers/spree/api/v3/shipping_label_event_serializer.rb +1 -1
  136. data/app/serializers/spree/api/v3/stock_movement_serializer.rb +1 -1
  137. data/app/serializers/spree/api/v3/stock_receipt_event_serializer.rb +1 -1
  138. data/app/serializers/spree/api/v3/variant_serializer.rb +2 -2
  139. data/app/services/spree/api/v3/filter_table.rb +22 -3
  140. data/app/services/spree/api/v3/request_schema.rb +64 -17
  141. data/config/locales/en.yml +1 -0
  142. data/config/routes.rb +80 -12
  143. data/lib/spree/api/agent_membership_map.rb +73 -0
  144. data/lib/spree/api/agent_resource_map.rb +350 -0
  145. data/lib/spree/api/agent_workflow_endpoints.rb +113 -0
  146. data/lib/spree/api/agent_write_schemas.rb +131 -0
  147. data/lib/spree/api/dependencies.rb +5 -4
  148. data/lib/spree/api/engine.rb +40 -0
  149. data/lib/spree/api/enum_types.rb +118 -0
  150. data/lib/spree/api/extension_fields.rb +167 -0
  151. data/lib/spree/api/oauth.rb +243 -0
  152. data/lib/spree/api/openapi/filter_documentation.rb +8 -1
  153. data/lib/spree/api/typelizer_generation.rb +5 -0
  154. data/lib/spree/api.rb +8 -0
  155. data/lib/typelizer/open_enums.rb +48 -10
  156. metadata +37 -6
@@ -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
@@ -0,0 +1,131 @@
1
+ module Spree
2
+ module Api
3
+ # The writable attribute schema for each admin resource, read from the
4
+ # generated OpenAPI document.
5
+ #
6
+ # Not from the controllers: most of them declare their writable attributes
7
+ # inside `permitted_params`, where the list is a `params.permit` call that
8
+ # cannot be read without a request. `admin.yaml` already carries the same
9
+ # list — generated from the integration specs, reviewed as a diff on every
10
+ # API change — with the types, descriptions and examples a model needs to
11
+ # fill a form in correctly. Reading it means the generic write tools offer
12
+ # exactly the documented endpoint and nothing else.
13
+ #
14
+ # Loaded once, lazily: the document is well over a megabyte, and an
15
+ # installation with no agent client should never pay for parsing it.
16
+ module AgentWriteSchemas
17
+ # A collection endpoint under the admin namespace: `/api/v3/admin/markets`
18
+ # but not `/api/v3/admin/markets/{id}` or a nested child.
19
+ COLLECTION_PATH = %r{\A/api/v3/admin/(?<key>[a-z0-9_]+)\z}
20
+
21
+ class << self
22
+ # The writable properties for one resource.
23
+ #
24
+ # @param key [String] the resource key, e.g. "markets"
25
+ # @return [Hash] JSON Schema properties, empty when the resource has no
26
+ # documented write
27
+ def for(key)
28
+ schemas[key.to_s] || {}
29
+ end
30
+
31
+ # @return [Array<String>] the attribute names a write may set
32
+ def attribute_names(key)
33
+ self.for(key).keys
34
+ end
35
+
36
+ # Where the generated document lives, relative to this engine. Resolved
37
+ # lazily: the engine class does not exist yet while this file loads.
38
+ #
39
+ # @return [Pathname]
40
+ def spec_path
41
+ Spree::Api::Engine.root.join('..', '..', 'docs', 'api-reference', 'admin.yaml')
42
+ end
43
+
44
+ # The parsed document, for a reader that needs more of it than the
45
+ # write bodies — the workflow endpoint map wants the member actions'
46
+ # bodies too, and parsing a megabyte of YAML twice is wasteful.
47
+ #
48
+ # @return [Hash]
49
+ def document
50
+ @document ||= begin
51
+ path = spec_path
52
+ File.exist?(path) ? (YAML.unsafe_load_file(path) || {}) : {}
53
+ rescue StandardError => e
54
+ Rails.logger.warn("[Spree] could not read the admin API document: #{e.class}: #{e.message}")
55
+ {}
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def schemas
62
+ @schemas ||= load_schemas
63
+ end
64
+
65
+ # A missing or unparseable document is not a boot failure: the generic
66
+ # write tools simply offer nothing, which is the safe direction. An
67
+ # installation running from the packaged gems has no docs/ directory at
68
+ # all.
69
+ def load_schemas
70
+ path = spec_path
71
+ return {} unless File.exist?(path)
72
+
73
+ document = YAML.unsafe_load_file(path)
74
+ extract(document)
75
+ rescue StandardError => e
76
+ Rails.logger.warn("[Spree] could not read admin API write schemas: #{e.class}: #{e.message}")
77
+ {}
78
+ end
79
+
80
+ def extract(document)
81
+ Array(document['paths']).to_h.each_with_object({}) do |(path, operations), result|
82
+ match = COLLECTION_PATH.match(path)
83
+ next if match.nil?
84
+
85
+ properties = write_properties(operations)
86
+ result[match[:key]] = properties if properties.present?
87
+ end
88
+ end
89
+
90
+ # The create body where there is one, because it documents every
91
+ # settable attribute; an update body usually repeats a subset.
92
+ def write_properties(operations)
93
+ body = operations.dig('post', 'requestBody', 'content', 'application/json', 'schema')
94
+ body ||= operations.dig('patch', 'requestBody', 'content', 'application/json', 'schema')
95
+
96
+ resolve_schema(body)&.dig('properties').presence
97
+ end
98
+
99
+ public
100
+
101
+ # A request body is declared once per endpoint and published as a
102
+ # component, so the operation carries a `$ref` to it rather than the
103
+ # properties themselves. A body that builds on a shared shape is an
104
+ # `allOf` of that shape and its own fields, read as one object.
105
+ # Followed a few hops, which is as deep as the generated document
106
+ # nests them.
107
+ #
108
+ # @param schema [Hash, nil] a schema that may be a reference
109
+ # @return [Hash, nil] the schema it names
110
+ def resolve_schema(schema, depth = 0)
111
+ return unless schema.is_a?(Hash)
112
+ return if depth > 3
113
+ return merge_all_of(schema['allOf'], depth) if schema.key?('allOf')
114
+ return schema unless schema.key?('$ref')
115
+
116
+ name = schema['$ref'].to_s.split('/').last
117
+ resolve_schema(document.dig('components', 'schemas', name), depth + 1)
118
+ end
119
+
120
+ private
121
+
122
+ def merge_all_of(parts, depth)
123
+ parts.filter_map { |part| resolve_schema(part, depth + 1) }.each_with_object({}) do |part, merged|
124
+ merged['properties'] = (merged['properties'] || {}).merge(part['properties'] || {})
125
+ merged['required'] = (merged['required'] || []) | Array(part['required'])
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -9,7 +9,7 @@ module Spree
9
9
  STORE_SERIALIZERS =
10
10
  %i[credit_card price price_history product product_type variant media option_type option_value cart order
11
11
  order_group line_item payment payment_session payment_setup_session payment_source store_credit
12
- fulfillment delivery address customer data_request customer_group country channel product_publication
12
+ fulfillment delivery address address_owner customer data_request customer_group country channel product_publication
13
13
  market state wishlist wishlist_item payment_method delivery_method delivery_rate freight_summary
14
14
  stock_location category collection applied_promotion discount tax_line delivery_zone delivery_zone_member
15
15
  fee digital_link gift_card currency locale policy custom_field tax_category tax_identifier
@@ -32,13 +32,14 @@ module Spree
32
32
  exchange exchange_line_item claim claim_line_item package_type policy tax_category tax_rate
33
33
  tax_identifier company company_membership company_invitation catalog catalog_price catalog_price_tier
34
34
  catalog_product price_list_product catalog_assignment catalog_quantity_rule catalog_product_term
35
- catalog_order_minimum tax_exemption_certificate admin_user actor seller_team_member product_submission
35
+ catalog_order_minimum tax_exemption_certificate tax_exemption_reason admin_user actor seller_team_member product_submission
36
36
  seller seller_requirement seller_requirement_submission seller_requirement_status commission_rate
37
- commission_rule commission_line seller_transfer seller_payout seller_balance payment_split address
37
+ commission_rule commission_line seller_transfer seller_payout seller_balance payment_split payment_capture_event address address_owner
38
38
  channel order_routing_rule product_publication market delivery_method delivery_method_service
39
39
  stock_location stock_reservation delivery_rate freight_summary freight_summary_line payment_method
40
40
  credit_card store_credit store_credit_event customer_group payment_source digital_asset digital_link
41
- store setup_task update_status api_key allowed_origin webhook_endpoint webhook_event webhook_delivery invitation
41
+ store setup_task update_status api_key oauth_application oauth_access_token oauth_consent_request oauth_redirect
42
+ oauth_protected_resource allowed_origin webhook_endpoint webhook_event webhook_delivery invitation
42
43
  invitation_acceptance_link role permission export saved_report import import_row import_mapping promotion
43
44
  promotion_action promotion_rule coupon_code price_adjustment_tier price_list price_rule
44
45
  resource_translations email_template email_template_draft email_template_revision email_template_preview
@@ -2,6 +2,11 @@ require 'rails/engine'
2
2
 
3
3
  require_relative 'dependencies'
4
4
  require_relative 'configuration'
5
+ require_relative 'agent_write_schemas'
6
+ require_relative 'agent_resource_map'
7
+ require_relative 'agent_membership_map'
8
+ require_relative 'agent_workflow_endpoints'
9
+ require_relative 'oauth'
5
10
 
6
11
  module Spree
7
12
  module Api
@@ -14,11 +19,46 @@ module Spree
14
19
  Spree::Api::Dependencies = Spree::Api::ApiDependencies.new
15
20
  end
16
21
 
22
+ # Applied from the engine so an installation gets a correct, locked-down
23
+ # authorization server by adding the gem.
24
+ #
25
+ # Before host initializers, not after: `Doorkeeper.configure` replaces
26
+ # the whole configuration, so running later would silently discard a
27
+ # host's own `config/initializers/doorkeeper.rb`. This way the host's
28
+ # file wins, which is what "configure it yourself" has to mean.
29
+ initializer 'spree.api.oauth', before: :load_config_initializers do |_app|
30
+ Spree::Api::Oauth.configure!
31
+
32
+ # The Admin API as a whole, for an app that works across it rather
33
+ # than through one endpoint. A token naming this audience is accepted
34
+ # on every admin controller; one naming a narrower resource — the MCP
35
+ # endpoint, say — is not, so a token taken from an MCP client cannot
36
+ # be replayed against the rest of the API.
37
+ Spree::Api::Oauth.register_resource(:admin, '/api/v3/admin')
38
+ end
39
+
40
+ # Extensions register their permission scopes from config initializers,
41
+ # which run after the configuration above — so the grantable list is
42
+ # re-read once they have.
43
+ config.after_initialize do
44
+ Spree::Api::Oauth.refresh_scopes!
45
+ end
46
+
17
47
  initializer 'spree.api.request_size_limit' do |app|
18
48
  require_relative 'middleware/request_size_limit'
19
49
  app.middleware.insert_before Rack::Runtime, Spree::Api::Middleware::RequestSizeLimit
20
50
  end
21
51
 
52
+ # The resources agents may read and write are derived from the admin
53
+ # controllers themselves (see Spree::Api::AgentResourceMap). Marked
54
+ # stale on every reload rather than rebuilt: deriving it eager-loads the
55
+ # application, and doing that on each file save would turn Spree's
56
+ # development reload from lazy into fully eager for everyone, whether or
57
+ # not they run an agent client. The first caller pays instead.
58
+ config.to_prepare do
59
+ Spree::Api::AgentResourceMap.stale!
60
+ end
61
+
22
62
  # Add API event subscribers
23
63
  config.after_initialize do
24
64
  Spree.subscribers << Spree::WebhookEventSubscriber