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
@@ -75,10 +75,16 @@ module Spree
75
75
  [:user, { attachment_attachment: :blob }]
76
76
  end
77
77
 
78
+ # Narrowed to the kinds this caller may read.
79
+ #
80
+ # Filtered for every principal that carries a ceiling, not only a
81
+ # secret key: an OAuth token resolves as a staff user with no key,
82
+ # so testing for one let an agent list every export in the store —
83
+ # including that customer and gift-card exports exist — whatever the
84
+ # merchant consented to. A staff member on a narrow role was
85
+ # unfiltered for the same reason.
78
86
  def scope
79
87
  collection = super
80
- return collection unless scope_limited_principal?
81
-
82
88
  collection.where(type: readable_export_types)
83
89
  end
84
90
 
@@ -144,10 +150,16 @@ module Spree
144
150
  action_name == 'create' ? resolve_export_type(params[:type]) : find_resource.class
145
151
  end
146
152
 
153
+ # `read_all` is not a catalog key, so it is asked of a secret key
154
+ # directly; every shipped export declares a required scope, and an
155
+ # extension type that declares none is readable only by a key that
156
+ # holds everything.
147
157
  def readable_export_types
148
158
  Spree::Export.available_types.select do |type|
149
159
  required = type.required_scope
150
- required ? current_api_key.has_scope?("read_#{required}") : current_api_key.has_scope?('read_all')
160
+ next current_api_key&.has_scope?('read_all') if required.blank?
161
+
162
+ holds_permission?("read_#{required}")
151
163
  end.map(&:to_s)
152
164
  end
153
165
  end
@@ -59,14 +59,7 @@ module Spree
59
59
  return unless params.key?(:customer_id)
60
60
  return @customer = nil if params[:customer_id].blank?
61
61
 
62
- unless holds_permission?('read_customers')
63
- return render_error(
64
- code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
65
- message: 'Missing permission: read_customers',
66
- status: :forbidden,
67
- details: { required_permission: 'read_customers' }
68
- )
69
- end
62
+ return render_missing_permission('read_customers') unless holds_permission?('read_customers')
70
63
 
71
64
  @customer = Spree.customer_class.
72
65
  accessible_by(current_ability, :show).
@@ -247,10 +247,15 @@ module Spree
247
247
  [:user, :mappings, { attachment_attachment: :blob }]
248
248
  end
249
249
 
250
+ # Narrowed to the kinds this caller may write.
251
+ #
252
+ # Filtered for every principal that carries a ceiling, not only a
253
+ # secret key — see the matching note on Admin::ExportsController.
254
+ # It matters more here: an import row carries `sample_row`, a real
255
+ # line from the uploaded file, so an unfiltered list handed a
256
+ # read-only agent live customer data rather than only metadata.
250
257
  def scope
251
258
  collection = super
252
- return collection unless scope_limited_principal?
253
-
254
259
  collection.where(type: writable_import_types)
255
260
  end
256
261
 
@@ -363,10 +368,14 @@ module Spree
363
368
  end
364
369
  end
365
370
 
371
+ # `write_all` is not a catalog key, so it is asked of a secret key
372
+ # directly; every shipped import declares a required scope.
366
373
  def writable_import_types
367
374
  Spree::Import.available_types.select do |type|
368
375
  required = type.required_scope
369
- required ? current_api_key.has_scope?("write_#{required}") : current_api_key.has_scope?('write_all')
376
+ next current_api_key&.has_scope?('write_all') if required.blank?
377
+
378
+ holds_permission?("write_#{required}")
370
379
  end.map(&:to_s)
371
380
  end
372
381
 
@@ -26,7 +26,9 @@ module Spree
26
26
  required: true, on: :create,
27
27
  example: 'de',
28
28
  description: 'Locale used as the market default. Always part of `supported_locales`.'
29
- body.attribute :tax_inclusive, :boolean, description: 'Display prices with tax included.'
29
+ body.attribute :tax_inclusive, :boolean,
30
+ description: 'Whether the prices entered for this market include tax. True charges every customer the ' \
31
+ 'price as entered, with their tax inside it; false adds tax at checkout.'
30
32
  body.attribute :tax_provider, :string,
31
33
  nullable: true,
32
34
  example: 'internal',
@@ -45,6 +45,8 @@ module Spree
45
45
  # *other* users. `avatar_signed_id` takes a signed id from
46
46
  # POST /files to set the photo, or `null` to remove it.
47
47
  def update
48
+ return refuse_delegated_write if current_oauth_token.present?
49
+
48
50
  if current_user.update(permitted_params)
49
51
  render json: me_response
50
52
  else
@@ -54,6 +56,20 @@ module Spree
54
56
 
55
57
  private
56
58
 
59
+ # A grant acts *for* a staff member, not *as* one. This action is
60
+ # unguarded because it was only ever the person at the keyboard
61
+ # editing their own profile — but a delegated credential reaches it
62
+ # too, and the name it could rewrite is the one audit trails show
63
+ # as the actor. There is no catalog permission for "edit my own
64
+ # profile", so the answer is that an agent has no business here.
65
+ def refuse_delegated_write
66
+ render_error(
67
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
68
+ message: 'A connected agent cannot change the profile of the person who connected it.',
69
+ status: :forbidden
70
+ )
71
+ end
72
+
57
73
  # A request authenticated by a secret API key has no Spree user to
58
74
  # describe/update, so it gets a 404 pointing at the key endpoint
59
75
  # rather than a 500 from a nil user — mirroring ApiKeysController#current.
@@ -82,11 +98,15 @@ module Spree
82
98
  params.permit(*declared_permitted_attributes)
83
99
  end
84
100
 
101
+ # What this credential may do, which is not always what its owner
102
+ # may do: a grant narrows the approver's role, so reporting the
103
+ # role would both disclose more than was handed over and mislead a
104
+ # client that reads /me to decide what to attempt.
85
105
  def me_response
86
106
  {
87
107
  user: admin_user_serializer.new(current_user, params: serializer_params).to_h,
88
108
  permissions: serialize_permissions(current_ability),
89
- permission_keys: serialize_permission_keys(current_ability)
109
+ permission_keys: staff_permission_keys
90
110
  }
91
111
  end
92
112
 
@@ -0,0 +1,56 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ module Oauth
6
+ # A connected client's live tokens — one per person who connected it.
7
+ #
8
+ # A store registers a client once and every staff member connects to
9
+ # that same registration, so the application row says that Claude is
10
+ # connected while these say by whom. Revoking one cuts off that
11
+ # person and leaves their colleagues working; deleting the
12
+ # application removes the registration itself.
13
+ class ApplicationTokensController < Spree::Api::V3::Admin::ResourceController
14
+ scoped_resource :oauth_applications
15
+
16
+ # Revoking rather than deleting: the row records who held access,
17
+ # and Doorkeeper treats a revoked token as gone on the next call.
18
+ def destroy
19
+ @resource.revoke
20
+ head :no_content
21
+ end
22
+
23
+ protected
24
+
25
+ def model_class
26
+ Spree::OauthAccessToken
27
+ end
28
+
29
+ def serializer_class
30
+ Spree.api.admin_oauth_access_token_serializer
31
+ end
32
+
33
+ # Live means unrevoked, not unexpired: an access token lasts
34
+ # hours while the refresh token beside it lasts far longer, so a
35
+ # person whose access has lapsed can mint another at will.
36
+ # Hiding them would hide a connection from the screen that exists
37
+ # to cut it off.
38
+ def scope
39
+ application.live_access_tokens.order(created_at: :desc)
40
+ end
41
+
42
+ def collection_includes
43
+ [:resource_owner]
44
+ end
45
+
46
+ # Nested under its application, so tenancy comes from the parent
47
+ # rather than from the token's own store column.
48
+ def application
49
+ @application ||= current_store.oauth_applications.find_by_prefix_id!(params[:application_id])
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,55 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ module Oauth
6
+ # Connected applications, so a merchant can see which agents and
7
+ # apps hold access to the store and take it away.
8
+ #
9
+ # Revoking is the off switch and has to be immediate: it revokes
10
+ # every live token and grant rather than deleting the registration,
11
+ # so a client that still holds a token stops working on its next
12
+ # call.
13
+ class ApplicationsController < Spree::Api::V3::Admin::ResourceController
14
+ # Its own permission rather than store configuration: a standing
15
+ # credential to the back office is not the same kind of thing as
16
+ # a delivery zone.
17
+ scoped_resource :oauth_applications
18
+
19
+ # `confidential` is not settable: these are hosted connectors that
20
+ # cannot keep a secret, which is why PKCE is mandatory. Neither is
21
+ # `scopes` — what a client may do is granted by the merchant at
22
+ # consent, not fixed at registration.
23
+ request_params :oauth_application do |body|
24
+ body.attribute :name, :string, required: true, example: 'Claude'
25
+ body.attribute :redirect_uri, :string,
26
+ description: 'Where the authorization server sends the browser back to.',
27
+ example: 'https://claude.ai/api/mcp/auth_callback'
28
+ end
29
+
30
+ protected
31
+
32
+ def model_class
33
+ Spree::OauthApplication
34
+ end
35
+
36
+ def serializer_class
37
+ Spree.api.admin_oauth_application_serializer
38
+ end
39
+
40
+ def scope
41
+ super.order(:name)
42
+ end
43
+
44
+ # The serializer reads each application's scopes, last
45
+ # authorization and who approved it from the live tokens.
46
+ def collection_includes
47
+ { live_access_tokens: :resource_owner }
48
+ end
49
+
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,294 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ module Oauth
6
+ # The consent step, as JSON for the dashboard to render.
7
+ #
8
+ # `show` describes what a client is asking for so the dashboard can
9
+ # draw a consent screen in its own design system, with the admin
10
+ # already signed in. `create` grants it and answers with the
11
+ # redirect the browser should follow; `destroy` denies it.
12
+ #
13
+ # Doorkeeper's own controller is not used: it renders HTML and runs
14
+ # its own login, and the dashboard already knows who is signed in.
15
+ class AuthorizationsController < Spree::Api::V3::Admin::BaseController
16
+ # Consent is a person's decision about their own authority, and a
17
+ # secret key has no human behind it, so there is no scope that
18
+ # could make this callable with one — `require_signed_in_admin!`
19
+ # refuses a key outright. Skipped for every principal rather than
20
+ # JWT-only, because leaving the key path in the scope machinery
21
+ # asks this controller for a `scoped_resource` it cannot
22
+ # meaningfully name.
23
+ skip_scope_check!
24
+
25
+ # Raised when Doorkeeper refuses the bounded pre-authorization.
26
+ # An exception rather than a render, because the refusal happens
27
+ # inside a helper the action reads a value from — returning there
28
+ # would hand the action a rendered body to call methods on.
29
+ class InvalidAuthorization < StandardError; end
30
+
31
+ rescue_from InvalidAuthorization do |error|
32
+ render_error(
33
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:resource_invalid],
34
+ message: error.message,
35
+ status: :unprocessable_content
36
+ )
37
+ end
38
+
39
+ before_action :require_signed_in_admin!
40
+ before_action :require_connect_permission!
41
+ before_action :require_client_of_this_store!
42
+ before_action :load_pre_authorization
43
+ before_action :require_something_to_grant!, only: :create
44
+
45
+ def show
46
+ request = Spree::OauthConsentRequest.new(
47
+ client_name: client_name,
48
+ scopes: requested_permissions,
49
+ # What the merchant may hand over, which is the intersection
50
+ # of what the client asked for and what they themselves hold.
51
+ # A grant can only ever narrow their own authority, so
52
+ # offering more than they have would be offering a refusal.
53
+ grantable_scopes: grantable_scopes,
54
+ resource: requested_resource,
55
+ redirect_uri: @pre_auth.redirect_uri
56
+ )
57
+
58
+ render json: Spree.api.admin_oauth_consent_request_serializer.new(request).to_h
59
+ end
60
+
61
+ # The merchant may approve less than the client asked for, and
62
+ # never more than they themselves hold.
63
+ #
64
+ # The ceiling is applied here rather than trusted from the
65
+ # request. Doorkeeper validates a requested scope against the
66
+ # catalog, not against the person, so a crafted POST naming
67
+ # `write_settings` would otherwise be recorded verbatim on a
68
+ # staffer who holds only `read_products` — and a grant is a
69
+ # standing ceiling, so it would widen on its own the day that
70
+ # person is promoted.
71
+ def create
72
+ render_redirect(granted_code_request.authorize.redirect_uri)
73
+ end
74
+
75
+ def destroy
76
+ render_redirect(code_request.deny.redirect_uri)
77
+ end
78
+
79
+ private
80
+
81
+ def render_redirect(uri)
82
+ redirect = Spree::OauthRedirect.new(redirect_uri: uri)
83
+
84
+ render json: Spree.api.admin_oauth_redirect_serializer.new(redirect).to_h
85
+ end
86
+
87
+ # A secret key authenticates but is not a person; `current_actor`
88
+ # returns the key in that case, and a grant must belong to a user.
89
+ # Connecting an agent is store configuration, like the screen that
90
+ # lists what is connected and the button that revokes one.
91
+ #
92
+ # Narrowing a grant to the approver's own permissions bounds what
93
+ # the agent may do; it does not answer who may hand a third party
94
+ # a standing credential at all. A staffer who cannot revoke an
95
+ # agent should not be able to connect one.
96
+ def require_connect_permission!
97
+ # Narrowed too: connecting an agent is a write a delegated
98
+ # credential must hold in its own scopes, not merely inherit.
99
+ return if holds_permission?('write_oauth_applications') &&
100
+ current_ability.can?(:update, Spree::OauthApplication)
101
+
102
+ render_error(
103
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
104
+ message: 'You do not have permission to connect an agent to this store.',
105
+ status: :forbidden
106
+ )
107
+ end
108
+
109
+ def require_signed_in_admin!
110
+ return if current_actor.is_a?(Spree.admin_user_class)
111
+
112
+ render_error(
113
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
114
+ message: 'Only a signed-in admin user can authorize an application.',
115
+ status: :forbidden
116
+ )
117
+ end
118
+
119
+ # Doorkeeper resolves a client id globally, so without this an
120
+ # admin of one store could consent to another store's registered
121
+ # client. Reading it through the store's own association turns
122
+ # that into a 404, the same defence every other admin lookup
123
+ # uses.
124
+ def require_client_of_this_store!
125
+ return if current_store.oauth_applications.exists?(uid: params[:client_id])
126
+
127
+ render_error(
128
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:record_not_found],
129
+ message: 'Unknown application.',
130
+ status: :not_found
131
+ )
132
+ end
133
+
134
+ def load_pre_authorization
135
+ @pre_auth = ::Doorkeeper::OAuth::PreAuthorization.new(
136
+ ::Doorkeeper.config,
137
+ pre_authorization_params,
138
+ current_actor
139
+ )
140
+
141
+ return if @pre_auth.authorizable?
142
+
143
+ render_error(
144
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:resource_invalid],
145
+ message: @pre_auth.error_response.body[:error_description].to_s,
146
+ status: :unprocessable_content
147
+ )
148
+ end
149
+
150
+ # What this person can actually grant: the requested scopes minus
151
+ # anything their own role does not carry.
152
+ #
153
+ # @return [Array<String>]
154
+ # Every individual permission the merchant could hand over, which
155
+ # is what the consent screen draws a row for.
156
+ def grantable_scopes
157
+ requested = Spree.permissions.expand_keys(@pre_auth.scopes.to_a)
158
+
159
+ # `staff_permission_keys` rather than the role's own keys: when
160
+ # the request arrived on a delegated credential it is already
161
+ # narrowed to that credential's scopes. Reading the role
162
+ # directly let an agent holding `read_products` approve itself
163
+ # a grant carrying everything its owner can do.
164
+ (requested & staff_permission_keys).sort
165
+ end
166
+
167
+
168
+
169
+
170
+ # Symbol keys: PreAuthorization reads them that way, and a hash of
171
+ # strings silently produces an unauthorizable request.
172
+ def pre_authorization_params
173
+ params.permit(
174
+ :client_id, :redirect_uri, :response_type, :state, :scope,
175
+ :code_challenge, :code_challenge_method, :response_mode
176
+ ).to_h.symbolize_keys.
177
+ merge(scope: known_scopes).
178
+ # The audience the grant is written with. Resolved rather
179
+ # than carried through raw, so a client that names nothing —
180
+ # the indicator is optional and most omit it — still gets a
181
+ # grant bound to the Admin API instead of to nothing.
182
+ merge(resource: requested_resource)
183
+ end
184
+
185
+ # The requested scopes this server knows, with the rest dropped.
186
+ #
187
+ # RFC 6749 §3.3 lets an authorization server ignore part of a
188
+ # requested scope, and the consent screen narrows to what the
189
+ # approver holds regardless. Refusing the whole request instead
190
+ # fails a client that cached an older catalogue — and the MCP
191
+ # spec tells a client to ask for everything it was once told
192
+ # about, so a scope we stop offering would lock out every client
193
+ # that discovered us before.
194
+ def known_scopes
195
+ accepted = Spree::Api::Oauth.staff_scope_keys.map(&:to_s)
196
+
197
+ params[:scope].to_s.split.select { |scope| accepted.include?(scope) }.join(' ')
198
+ end
199
+
200
+ # The audiences the grant is bound to.
201
+ #
202
+ # Every grant is bound to the Admin API, whatever the client
203
+ # asked for. The MCP endpoint lives inside that API and
204
+ # authenticates the same way, so one audience serves both — and
205
+ # a tool call, which reaches data by dispatching into the Admin
206
+ # API with this same token, is not left carrying a credential
207
+ # bound to a path the Admin API does not serve.
208
+ #
209
+ # The indicator a client sends is honoured for its origin only:
210
+ # a tunnel, a proxy or a custom domain has to appear in the
211
+ # audience as the client knows it.
212
+ def requested_resource
213
+ requested = Array(params[:resource]).flat_map { |value| value.to_s.split }
214
+
215
+ Spree::Api::Oauth.resource_identifier(:admin, origin_of(requested.first))
216
+ end
217
+
218
+ # The scheme and host the client was given — a tunnel, a proxy, a
219
+ # custom domain — so both audiences name the same origin.
220
+ def origin_of(indicator)
221
+ uri = URI.parse(indicator.to_s)
222
+ return if uri.host.blank?
223
+
224
+ "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.default_port == uri.port}"
225
+ rescue URI::InvalidURIError
226
+ nil
227
+ end
228
+
229
+ def path_of(value)
230
+ URI.parse(value.to_s).path
231
+ rescue URI::InvalidURIError
232
+ nil
233
+ end
234
+
235
+ # Every capability the requested scopes imply, so a merchant sees
236
+ # what they are actually granting rather than only the shorthand
237
+ # the client named.
238
+ def requested_permissions
239
+ Spree.permissions.expand_keys(@pre_auth.scopes.to_a).sort
240
+ end
241
+
242
+ def client_name
243
+ @pre_auth.client&.application&.name.presence || 'An application'
244
+ end
245
+
246
+ # Doorkeeper's own request object builds both redirects. Hand-
247
+ # rolling them means getting out-of-band clients, fragment
248
+ # response mode and the RFC 9207 issuer parameter right by hand,
249
+ # which is exactly the work the gem is here to do.
250
+ def code_request
251
+ @code_request ||= ::Doorkeeper::OAuth::CodeRequest.new(@pre_auth, current_actor)
252
+ end
253
+
254
+ # The same thing, over a pre-authorization whose scope has been
255
+ # cut to what this person can actually hand over. Rebuilt rather
256
+ # than mutated, because the scope is read in several places on
257
+ # the way to the grant.
258
+ # A grant that carries nothing is not a grant. It happens when the
259
+ # merchant clears every box, or when the client asks only for
260
+ # permissions this person does not hold, and it would otherwise
261
+ # mint a token that authorizes nothing while the client reports a
262
+ # successful connection and the merchant sees the application
263
+ # listed as connected.
264
+ def require_something_to_grant!
265
+ return if grantable_scopes.any?
266
+
267
+ render_error(
268
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:resource_invalid],
269
+ message: 'Select at least one permission to grant.',
270
+ status: :unprocessable_content
271
+ )
272
+ end
273
+
274
+ def granted_code_request
275
+ bounded = ::Doorkeeper::OAuth::PreAuthorization.new(
276
+ ::Doorkeeper.config,
277
+ pre_authorization_params.merge(scope: grantable_scopes.join(' ')),
278
+ current_actor
279
+ )
280
+
281
+ # Validation is what resolves `client`, and the grant is written
282
+ # from `pre_auth.client.id` — an unvalidated pre-authorization
283
+ # carries a nil client and fails deep inside the gem.
284
+ raise InvalidAuthorization, bounded.error_response.body[:error_description].to_s unless
285
+ bounded.authorizable?
286
+
287
+ ::Doorkeeper::OAuth::CodeRequest.new(bounded, current_actor)
288
+ end
289
+ end
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end
@@ -32,7 +32,7 @@ module Spree
32
32
  def collection_includes
33
33
  [
34
34
  :customer, :ship_address, :bill_address,
35
- { payments: %i[payment_method source] },
35
+ { payments: %i[payment_method source capture_events] },
36
36
  { orders: %i[market gift_card] }
37
37
  ]
38
38
  end
@@ -15,6 +15,7 @@ module Spree
15
15
  scoped_resource :fulfillments
16
16
 
17
17
  request_params :fulfillment do |body|
18
+ body.external_references
18
19
  body.attribute :stock_location_id, :id,
19
20
  required: true, on: :create,
20
21
  description: "The location the fulfillment ships from. On update, moving it re-prices the fulfillment against that location's rates."
@@ -140,6 +141,13 @@ module Spree
140
141
  params.permit(*permitted_attributes)
141
142
  end
142
143
 
144
+ # A replayed create runs the update action, which takes neither the
145
+ # units to move nor how the parcel was registered: those only shape
146
+ # a new fulfillment.
147
+ def normalize_upsert_params!
148
+ %i[items source_fulfillment_id delivery_method_id cost status metadata].each { |key| params.delete(key) }
149
+ end
150
+
143
151
  private
144
152
 
145
153
  # Forcing past a stock or payment guard is the operator's call.
@@ -5,6 +5,7 @@ module Spree
5
5
  module Orders
6
6
  class ItemsController < BaseController
7
7
  request_params :line_item do |body|
8
+ body.external_references
8
9
  body.attribute :variant_id, :id, required: true, only: :create
9
10
  body.attribute :quantity, :integer, example: 1, description: 'Defaults to 1 when adding an item.'
10
11
  body.attribute :price, :decimal,
@@ -20,6 +20,7 @@ module Spree
20
20
  scoped_resource :orders
21
21
 
22
22
  request_params :return do |body|
23
+ body.external_references
23
24
  body.attribute :items, :array,
24
25
  required: true, on: :create,
25
26
  only: :create,
@@ -89,6 +90,12 @@ module Spree
89
90
  params.permit(*permitted_attributes)
90
91
  end
91
92
 
93
+ # A replayed create runs the update action, and the units coming
94
+ # back are fixed once the return exists.
95
+ def normalize_upsert_params!
96
+ params.delete(:items)
97
+ end
98
+
92
99
  private
93
100
 
94
101
  # The ids are resolved through the store rather than assigned raw,