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
@@ -17,10 +17,12 @@ module Spree
17
17
  request_params :order do |body|
18
18
  body.external_references
19
19
  body.attribute :email, :string, example: 'customer@example.com'
20
- body.attribute :customer_id, :id
20
+ body.attribute :customer_id, :id,
21
+ nullable: true,
22
+ description: 'Null detaches the customer, leaving a guest order under its `email`.'
21
23
  body.attribute :use_customer_default_address, :boolean,
22
24
  only: :create,
23
- description: "With `customer_id`, copies the customer's saved billing and shipping addresses onto the order."
25
+ description: "Copies the saved default billing and shipping addresses onto the order. Sent with `company_id`, it takes that company's defaults, inherited from its parent nodes when the company holds none of its own; otherwise it takes the customer's."
24
26
  body.attribute :currency, :currency, example: 'USD'
25
27
  body.attribute :market_id, :id
26
28
  body.attribute :channel_id, :id, description: 'Defaults to the store primary channel when omitted.'
@@ -35,10 +37,10 @@ module Spree
35
37
  description: "The `signed_id` of the buyer's purchase order, from `files.upload()` with `visibility: 'private'`. Null removes the document the order carries."
36
38
  body.attribute :shipping_address_id, :id,
37
39
  only: :create,
38
- description: 'An existing address of the customer.'
40
+ description: "An existing address from the customer's address book, or from the address book of the company the order is for."
39
41
  body.attribute :billing_address_id, :id,
40
42
  only: :create,
41
- description: 'An existing address of the customer.'
43
+ description: "An existing address from the customer's address book, or from the address book of the company the order is for."
42
44
  body.attribute :preferred_stock_location_id, :id,
43
45
  description: "Stock location to prefer for fulfillment. Order Routing's built-in preferred-location rule ranks it first; routing falls back to the next rule when the location can't cover the cart."
44
46
  body.attribute :company_id, :id,
@@ -51,6 +53,8 @@ module Spree
51
53
  body.attribute :tags, :array, of: :string
52
54
  body.attribute :shipping_address, :object, shape: ADDRESS
53
55
  body.attribute :billing_address, :object, shape: ADDRESS
56
+ body.attribute :use_shipping, :boolean,
57
+ description: 'When true, copies the shipping address to the billing address.'
54
58
  body.attribute :items, :array,
55
59
  description: 'Line items to upsert, keyed by variant. An entry sets that variant\'s quantity, creating the line item when the order has none; `quantity: 0` removes it. Variants you do not send are left alone.' do |item|
56
60
  item.attribute :variant_id, :id, required: true
@@ -295,8 +299,8 @@ module Spree
295
299
  # reads it — otherwise a page of orders costs a query per
296
300
  # fulfillment for a field that is nil on every parcel order.
297
301
  [:customer, :channel, :seller, :external_references, :cancel_reason,
298
- :market, { fulfillments: :selected_delivery_rate },
299
- { line_items: [{ variant: :prices }, { price_list: :catalog }] },
302
+ :market, { fulfillments: %i[selected_delivery_rate external_references] },
303
+ { line_items: [:external_references, { variant: :prices }, { price_list: :catalog }] },
300
304
  { po_document_attachment: :blob }]
301
305
  end
302
306
 
@@ -333,10 +337,19 @@ module Spree
333
337
  resolve_company_param(normalize_params(params.permit(*request_params_schema.permit_list(:create))))
334
338
  end
335
339
 
340
+ # `items` is the public name for line items, so the normalizer needs
341
+ # telling which model the rows describe — otherwise a `prod_` id in
342
+ # `variant_id` is not recognised as the wrong kind.
343
+ def nested_param_models
344
+ { 'items' => Spree::LineItem }
345
+ end
346
+
336
347
  # Named rather than read from the action, since an upsert reaches
337
348
  # update from the create action.
338
349
  def order_update_params
339
- resolve_company_param(normalize_params(params.permit(*request_params_schema.permit_list(:update))))
350
+ permitted = resolve_company_param(normalize_params(params.permit(*request_params_schema.permit_list(:update))))
351
+ permitted[:customer_id] = resolve_customer&.id if permitted.key?(:customer_id)
352
+ permitted
340
353
  end
341
354
 
342
355
  # An incidental lookup like any other: resolved through the store so
@@ -27,9 +27,8 @@ module Spree
27
27
  body.attribute :length, :number, nullable: true
28
28
  body.attribute :width, :number, nullable: true
29
29
  body.attribute :height, :number, nullable: true
30
- body.attribute :dimensions_unit, :string,
30
+ body.attribute :dimensions_unit, :dimension_unit,
31
31
  nullable: true,
32
- choices: Spree::Variant::DIMENSION_UNITS,
33
32
  example: 'cm',
34
33
  description: "Unit the three dimensions are in; falls back to the store's unit system."
35
34
  body.attribute :weight, :number,
@@ -38,7 +37,7 @@ module Spree
38
37
  body.attribute :max_weight, :number,
39
38
  nullable: true,
40
39
  description: 'What the package can hold, not the same as its own weight.'
41
- body.attribute :weight_unit, :string, nullable: true, choices: Spree::Variant::WEIGHT_UNITS, example: 'kg'
40
+ body.attribute :weight_unit, :weight_unit, nullable: true, example: 'kg'
42
41
  body.attribute :default, :boolean,
43
42
  description: 'The box every parcel quote is built on. Setting this demotes whichever row held it.'
44
43
  body.attribute :metadata, :hash
@@ -7,11 +7,10 @@ module Spree
7
7
  scoped_resource :products
8
8
 
9
9
  request_params :variant do |body|
10
+ body.extends Spree::Api::V3::Admin::VariantRequestSchema
10
11
  body.external_references
11
- body.include_attributes Spree::Api::V3::VariantRequestSchema
12
- body.attribute :options, :array, of: Spree::Api::V3::VariantOptionRequestSchema,
13
- required: true, on: :create,
14
- description: 'One pair per option type the variant takes part in (size, color). Option types and values are created when missing. On update, omit to leave the option values untouched.'
12
+ body.require_attributes :options, on: :create
13
+ body.describe_attribute :options, 'One pair per option type the variant takes part in (size, color). Option types and values are created when missing. On update, omit to leave the option values untouched.'
15
14
  end
16
15
 
17
16
  protected
@@ -77,10 +77,8 @@ module Spree
77
77
  body.attribute :variants, :array,
78
78
  as: 'ProductVariantParams',
79
79
  description: 'Every purchasable attribute (sku, prices, stock, weight, dimensions) lives on variants. An entry without `options` upserts onto the default variant of a simple product; pass `id` to update an existing variant.' do |variant|
80
+ variant.extends Spree::Api::V3::Admin::VariantRequestSchema
80
81
  variant.attribute :id, :id
81
- variant.include_attributes Spree::Api::V3::VariantRequestSchema
82
- variant.attribute :options, :array, of: Spree::Api::V3::VariantOptionRequestSchema,
83
- description: 'One pair per option type the variant takes part in (size, color). Option types and values are created when missing.'
84
82
  end
85
83
  end
86
84
 
@@ -291,6 +289,10 @@ module Spree
291
289
  Spree.product_update_workflow
292
290
  end
293
291
 
292
+ def nested_permitted_models
293
+ { media: Spree::Media, variants: Spree::Variant }
294
+ end
295
+
294
296
  # Use SearchProvider::Database for collection to handle price/best_selling
295
297
  # sorting correctly (counts before sorting, avoiding PG/Mobility issues).
296
298
  def collection
@@ -47,6 +47,7 @@ module Spree
47
47
  item.attribute :variant_id, :id, required: true, example: 'variant_1234567890'
48
48
  item.attribute :quantity_ordered, :integer, required: true, example: 100
49
49
  item.attribute :unit_cost, :decimal,
50
+ required: true,
50
51
  example: '12.50',
51
52
  description: 'What the merchant agreed to pay per unit.'
52
53
  end
@@ -87,27 +87,33 @@ module Spree
87
87
  params.to_unsafe_h.slice(*QUERY_KEYS)
88
88
  end
89
89
 
90
- # Both credential types are authorized per referenced member:
91
- # JWT admins via CanCanCan (`required_subjects`), secret keys via
92
- # their scopes (`required_key_scopes` on top of the `read_reports`
93
- # endpoint gate) — a key without `read_products` cannot query the
94
- # product dimension any more than a limited staff role can.
90
+ # Every referenced member is authorized against what this
91
+ # credential holds, on top of the `read_reports` endpoint gate: a
92
+ # caller without `read_products` cannot query the product dimension
93
+ # whatever its principal type.
94
+ #
95
+ # Asked through `holds_permission?` rather than of the ability,
96
+ # because an OAuth grant authenticates as the staff member who
97
+ # approved it and carries no API key. Measured against their role,
98
+ # a grant for `read_reports` alone could group by customer and read
99
+ # names, emails and spend — data the merchant never consented to.
95
100
  def authorize_reporting_members!(reporting_query)
96
- if current_api_key.present?
97
- missing = reporting_query.required_key_scopes.reject { |scope| current_api_key.has_scope?(scope) }
98
- return if missing.empty?
101
+ missing = reporting_query.required_key_scopes.reject { |scope| holds_permission?(scope) }
99
102
 
100
- render_error(
103
+ if missing.any?
104
+ return render_error(
101
105
  code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
102
- message: "API key lacks scope: #{missing.join(', ')}",
106
+ message: "Missing permission: #{missing.join(', ')}",
103
107
  status: :forbidden,
104
108
  details: { required_scopes: missing }
105
109
  )
106
- else
107
- authorize!(:read, :reports)
108
- forbidden = reporting_query.unreadable_subjects(current_ability).first
109
- raise CanCan::AccessDenied.new(nil, :read, forbidden) if forbidden
110
110
  end
111
+
112
+ # Then the record-level rules, which a host app uses to narrow
113
+ # what a role sees within data it may read at all.
114
+ authorize!(:read, :reports)
115
+ forbidden = reporting_query.unreadable_subjects(current_ability).first
116
+ raise CanCan::AccessDenied.new(nil, :read, forbidden) if forbidden
111
117
  end
112
118
 
113
119
  # An unknown member carries what it was and what was valid instead.
@@ -72,7 +72,7 @@ module Spree
72
72
  )
73
73
  end
74
74
 
75
- result = Spree.stock_level_bulk_upsert_service.call(rows: rows)
75
+ result = Spree.stock_level_bulk_upsert_workflow.call(store: current_store, rows: rows)
76
76
  render json: result.value
77
77
  end
78
78
 
@@ -9,7 +9,7 @@ module Spree
9
9
  body.attribute :name, :string, example: 'My Store'
10
10
  body.attribute :admin_locale, :string, example: 'en'
11
11
  body.attribute :timezone, :string, example: 'Europe/Berlin'
12
- body.attribute :weight_unit, :string, choices: Spree::Variant::WEIGHT_UNITS
12
+ body.attribute :weight_unit, :weight_unit
13
13
  body.attribute :unit_system, :string, choices: Spree::Store::UNIT_SYSTEMS
14
14
  body.attribute :storefront_access, :string,
15
15
  choices: Spree::Channel::Gating::STOREFRONT_ACCESS,
@@ -82,18 +82,21 @@ module Spree
82
82
  end
83
83
 
84
84
  # Per-type scope check for API-key principals: a key listing product
85
- # tags needs `read_products`, order tags `read_orders`, etc. JWT
86
- # admins are gated by store membership + CanCanCan, not scopes.
85
+ # tags needs `read_products`, order tags `read_orders`, etc.
86
+ #
87
+ # Asked of the credential, not of its kind: an OAuth grant carries
88
+ # no API key, so a check that began `return unless current_api_key`
89
+ # skipped itself entirely and handed an agent every order and
90
+ # customer tag the approver could see.
87
91
  def authorize_taggable_scope!
88
- return unless current_api_key
89
92
  return unless taggable_type
90
93
 
91
94
  required = self.class.scope_for_taggable_type.fetch(taggable_type, 'read_all')
92
- return if current_api_key.has_scope?(required)
95
+ return if holds_permission?(required)
93
96
 
94
97
  render_error(
95
98
  code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
96
- message: "API key lacks scope: #{required}",
99
+ message: "Missing permission: #{required}",
97
100
  status: :forbidden,
98
101
  details: { required_scope: required }
99
102
  )
@@ -0,0 +1,58 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class TaxExemptionReasonsController < ResourceController
6
+ scoped_resource :settings
7
+
8
+ request_params :tax_exemption_reason do |body|
9
+ body.include_attributes Spree::Api::V3::Admin::ReasonRequestSchema
10
+ body.attribute :code, :string, required: true, on: :create, example: 'RESALE',
11
+ description: 'What the tax provider receives for every certificate holding this reason — for Avalara, an entity use code such as `G`. ' \
12
+ 'Saved in capitals. Changing it changes what those certificates send from then on.'
13
+ body.attribute :metadata, :hash
14
+ end
15
+
16
+ protected
17
+
18
+ def model_class
19
+ Spree::TaxExemptionReason
20
+ end
21
+
22
+ def serializer_class
23
+ Spree.api.admin_tax_exemption_reason_serializer
24
+ end
25
+
26
+ def permitted_params
27
+ params.permit(*permitted_attributes)
28
+ end
29
+
30
+ # The certificate form picks from this list, so staff who manage
31
+ # customers read it without the settings key. Writing stays a
32
+ # settings matter.
33
+ def scoped_resource_name
34
+ reads_through_customers? ? :customers : super
35
+ end
36
+
37
+ def scope
38
+ reads_through_customers? ? current_store.tax_exemption_reasons : super
39
+ end
40
+
41
+ def authorize_resource!(resource = @resource, action = action_name.to_sym)
42
+ return authorize!(:show, Spree::TaxExemptionCertificate) if reads_through_customers?
43
+
44
+ super
45
+ end
46
+
47
+ def reads_through_customers?
48
+ return @reads_through_customers if defined?(@reads_through_customers)
49
+
50
+ @reads_through_customers = action_kind == 'read' &&
51
+ !holds_permission?('read_settings') &&
52
+ holds_permission?('read_customers')
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -13,7 +13,6 @@ module Spree
13
13
  example: '0.19',
14
14
  description: 'The rate as a fraction. Send this or `rate_percent`.'
15
15
  body.attribute :rate_percent, :decimal, example: '19', description: 'The rate as a percentage.'
16
- body.attribute :included_in_price, :boolean
17
16
  body.attribute :show_rate_in_label, :boolean
18
17
  body.attribute :tax_category_id, :id, required: true, on: :create
19
18
  body.attribute :country_code, :country,
@@ -20,9 +20,10 @@ module Spree
20
20
  # authorization, and rendering.
21
21
  class BatchesController < Admin::BaseController
22
22
  # The batch spans heterogeneous resource types, so a single static
23
- # scope can't gate it. We verify the API key holds the matching
24
- # write_<resource> scope for EVERY entry (see require_batch_scopes!),
25
- # which is stricter than the static check and keeps the audit model.
23
+ # scope can't gate it. We verify the caller holds the matching
24
+ # write_<resource> permission for EVERY entry (see
25
+ # require_batch_scopes!), which is stricter than the static check
26
+ # and keeps the audit model.
26
27
  skip_scope_check!
27
28
 
28
29
  request_params 'TranslationBatchParams' do |body|
@@ -63,19 +64,24 @@ module Spree
63
64
 
64
65
  private
65
66
 
66
- # For API-key callers, require write_<resource> for every distinct
67
- # resource type in the batch. JWT callers bypass (current_api_key is
68
- # nil) and rely on the per-record authorize!(:update, record) above.
69
- # Returns false (and renders 403) when a scope is missing.
67
+ # Require write_<resource> for every distinct resource type in the
68
+ # batch.
69
+ #
70
+ # Asked of the caller rather than of a secret key, because there
71
+ # are three principals and only one of them carries scopes on a
72
+ # key. An OAuth token resolves as a staff user with no key, so
73
+ # reading `current_api_key` here waved it through and the ceiling
74
+ # the merchant consented to went unenforced on the one write this
75
+ # controller performs — `holds_permission?` answers for all three.
76
+ #
77
+ # Returns false (and renders 403) when a permission is missing.
70
78
  def require_batch_scopes!(batch)
71
- return true unless current_api_key
72
-
73
- missing = batch.required_scopes.reject { |scope| current_api_key.has_scope?(scope) }
79
+ missing = batch.required_scopes.reject { |scope| holds_permission?(scope) }
74
80
  return true if missing.empty?
75
81
 
76
82
  render_error(
77
83
  code: ERROR_CODES[:access_denied],
78
- message: "API key lacks scope(s): #{missing.join(', ')}",
84
+ message: "You do not have permission to #{missing.map { |key| key.tr('_', ' ') }.to_sentence}.",
79
85
  status: :forbidden,
80
86
  details: { required_scopes: missing }
81
87
  )
@@ -5,6 +5,30 @@ module Spree
5
5
  class VariantsController < ResourceController
6
6
  scoped_resource :products
7
7
 
8
+ SALES_CONTEXT = Spree::Api::V3::RequestSchema.shape('VariantSalesContextParams') do |query|
9
+ query.attribute :order_id, :id,
10
+ description: 'A draft order to sell to: lists only what its buyer can be sold on its channel, ' \
11
+ 'priced in its market. Needs `read_orders`, and cannot be combined with the fields below.'
12
+ query.attribute :market_id, :id,
13
+ description: 'Prices in this market and lists only variants with a price in its currency.'
14
+ query.attribute :channel_id, :id,
15
+ description: 'Lists only variants published on this channel. When a market, customer, company or ' \
16
+ "currency is given without it, the store default channel applies; an order uses its own."
17
+ query.attribute :customer_id, :id,
18
+ description: "Narrows to the customer's catalogs and prices as they would pay. Needs `read_customers`."
19
+ query.attribute :company_id, :id,
20
+ description: "Narrows to the company's catalogs and prices under its agreement. Needs `read_customers`."
21
+ query.attribute :currency, :currency,
22
+ description: 'Lists only variants with a price in this currency. ' \
23
+ "With `market_id` it must be the market's currency."
24
+ end
25
+ SALES_CONTEXT_PARAMS = (SALES_CONTEXT.fields.keys - [:order_id]).freeze
26
+
27
+ # A draft order's item picker asks with the order's market, channel and
28
+ # buyer, so it lists only what that buyer can be sold there, priced as
29
+ # they would pay.
30
+ before_action :load_sales_context, only: :index
31
+
8
32
  protected
9
33
 
10
34
  def model_class
@@ -19,7 +43,21 @@ module Spree
19
43
  # relation from scratch dropped `scope_includes`, so every listed
20
44
  # variant fell back to per-row price and stock queries.
21
45
  def scope
22
- super.eligible.accessible_by(current_ability, ability_action_for_request)
46
+ variants = super.eligible.accessible_by(current_ability, ability_action_for_request)
47
+ @sales_context ? variants.where(id: @sales_context.sellable_variants.select(:id)) : variants
48
+ end
49
+
50
+ def serializer_params
51
+ return super unless @sales_context
52
+
53
+ super.merge(
54
+ currency: @sales_context.currency,
55
+ user: @sales_context.customer,
56
+ company: @sales_context.company,
57
+ market: @sales_context.market,
58
+ channel: @sales_context.channel,
59
+ country: @sales_context.country
60
+ )
23
61
  end
24
62
 
25
63
  # `active_stock_reservations` rides along with the stock levels:
@@ -33,6 +71,63 @@ module Spree
33
71
  { primary_media: [{ attachment_attachment: :blob }, { poster_attachment: :blob }] }
34
72
  ]
35
73
  end
74
+
75
+ private
76
+
77
+ def load_sales_context
78
+ requested = SALES_CONTEXT_PARAMS.select { |key| params[key].present? }
79
+ return if requested.empty? && params[:order_id].blank?
80
+
81
+ permission = required_sales_context_permission
82
+ return render_missing_permission(permission) if permission && !holds_permission?(permission)
83
+
84
+ if params[:order_id].present?
85
+ return refuse_order_with_context(requested) if requested.any?
86
+
87
+ @sales_context = Spree::SalesContext.from_order(context_order)
88
+ else
89
+ @sales_context = requested_sales_context
90
+ render_errors(@sales_context.errors) unless @sales_context.valid?
91
+ end
92
+ end
93
+
94
+ # An order already fixes its market, channel, buyer and currency, so
95
+ # naming them too could only disagree with it.
96
+ def refuse_order_with_context(requested)
97
+ render_error(
98
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:parameter_invalid],
99
+ message: I18n.t('spree.api.errors.order_sales_context_exclusive', params: requested.join(', ')),
100
+ status: :unprocessable_content,
101
+ details: { order_id: requested.map(&:to_s) }
102
+ )
103
+ end
104
+
105
+ def required_sales_context_permission
106
+ return 'read_orders' if params[:order_id].present?
107
+
108
+ 'read_customers' if params[:customer_id].present? || params[:company_id].present?
109
+ end
110
+
111
+ def requested_sales_context
112
+ Spree::SalesContext.new(
113
+ store: current_store,
114
+ market: find_requested(current_store.markets, :market_id),
115
+ channel: find_requested(current_store.channels, :channel_id),
116
+ customer: find_requested(
117
+ Spree.customer_class.for_store(current_store).accessible_by(current_ability, :show), :customer_id
118
+ ),
119
+ company: find_requested(current_store.companies, :company_id),
120
+ currency: params[:currency]
121
+ )
122
+ end
123
+
124
+ def find_requested(relation, key)
125
+ relation.find_by_prefix_id!(params[key]) if params[key].present?
126
+ end
127
+
128
+ def context_order
129
+ current_store.orders.accessible_by(current_ability, :show).find_by_prefix_id!(params[:order_id])
130
+ end
36
131
  end
37
132
  end
38
133
  end
@@ -0,0 +1,72 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Oauth
5
+ # The authorization endpoint a client sends the browser to.
6
+ #
7
+ # It renders nothing itself: consent belongs in the dashboard, where
8
+ # the merchant is already signed in and the screen matches the rest of
9
+ # the admin. This hands the request over with its parameters intact,
10
+ # and the dashboard reads them back through the JSON endpoints under
11
+ # /api/v3/admin/oauth.
12
+ #
13
+ # It is a real route rather than a skipped one because RFC 8414
14
+ # requires the metadata document to name an `authorization_endpoint`,
15
+ # and a consumer client refuses a document that advertises the
16
+ # authorization-code flow without one.
17
+ class AuthorizationsController < ActionController::Base
18
+ include Spree::Core::ControllerHelpers::Store
19
+
20
+ # The authorization endpoint is reached by a cross-site GET on
21
+ # purpose — a client sends the browser here — and forgery
22
+ # protection never applies to a GET, so `:exception` costs nothing
23
+ # while leaving the endpoint reachable. Nothing is granted here
24
+ # either way: the action only redirects, and consent is submitted
25
+ # to the Admin API under the merchant's own session.
26
+ protect_from_forgery with: :exception
27
+
28
+ # Carried across to the dashboard. Everything else is dropped, so a
29
+ # crafted link cannot smuggle extra parameters into the consent page.
30
+ FORWARDED_PARAMETERS = %w[
31
+ client_id redirect_uri response_type scope state
32
+ code_challenge code_challenge_method resource
33
+ ].freeze
34
+
35
+ def new
36
+ redirect_to consent_url, allow_other_host: true
37
+ end
38
+
39
+ private
40
+
41
+ # The client id names the store, not the hostname. An installation
42
+ # serving several stores from one API origin would otherwise send
43
+ # every consent to whichever store that host happens to resolve as,
44
+ # and the merchant would approve on the wrong one.
45
+ def consent_url
46
+ query = params.permit(*FORWARDED_PARAMETERS).to_h.compact_blank.to_query
47
+ base = Spree::Stores::DashboardUrl.call(store: current_store)
48
+
49
+ "#{base}/#{current_store.prefixed_id}/oauth/authorize?#{query}"
50
+ end
51
+
52
+ # The client's store wins over the hostname's, and is looked up
53
+ # before the hostname is resolved at all: resolving it assigns the
54
+ # default store to Spree::Current, and an app scoping queries by
55
+ # that store would then never find another store's client.
56
+ #
57
+ # @return [Spree::Store, nil]
58
+ def current_store
59
+ @current_store ||= client_application&.store || super
60
+ end
61
+
62
+ # @return [Spree::OauthApplication, nil]
63
+ def client_application
64
+ return if params[:client_id].blank?
65
+
66
+ Spree::OauthApplication.find_by(uid: params[:client_id])
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,57 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Oauth
5
+ # RFC 9728 Protected Resource Metadata — the one discovery document
6
+ # Doorkeeper does not ship (it serves RFC 8414 authorization-server
7
+ # metadata itself).
8
+ #
9
+ # A client reads this to learn which authorization server guards a
10
+ # resource. It is deliberately unauthenticated: a client has no
11
+ # credential at the point it asks.
12
+ class MetadataController < ActionController::API
13
+
14
+ # A public discovery document, read before any credential exists.
15
+ # The module has to be included because `ActionController::API`
16
+ # carries none of it; `:exception` is free here because the only
17
+ # action is a GET, which forgery protection never applies to.
18
+ include ActionController::RequestForgeryProtection
19
+ protect_from_forgery with: :exception
20
+
21
+ # The `resource` value must equal the URL the client was pointed at,
22
+ # path and all, or a consumer client refuses to continue — so it is
23
+ # built from this request's own origin rather than from anything
24
+ # configured elsewhere.
25
+ def show
26
+ resource = Spree::Api::Oauth.resource_identifier(params[:resource_key], request.base_url)
27
+ return head :not_found if resource.blank?
28
+
29
+ document = Spree::OauthProtectedResource.new(
30
+ resource: resource,
31
+ authorization_servers: [issuer],
32
+ # Exactly what the authorization server will accept.
33
+ #
34
+ # The spec tells a client with no scope challenge to request
35
+ # everything advertised here, and Doorkeeper refuses a request
36
+ # naming a scope it does not know — so advertising one more
37
+ # than it accepts turns every connection into "The requested
38
+ # scope is invalid".
39
+ scopes_supported: Spree::Api::Oauth.staff_scope_keys,
40
+ resource_documentation: 'https://spreecommerce.org/docs/developer/mcp/overview'
41
+ )
42
+
43
+ render json: Spree.api.admin_oauth_protected_resource_serializer.new(document).to_h
44
+ end
45
+
46
+ private
47
+
48
+ # The authorization server's own origin. This request reached it, so
49
+ # the origin it arrived on is the one a client can use.
50
+ def issuer
51
+ ::Doorkeeper.config.issuer.presence || request.base_url
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,57 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Oauth
5
+ # RFC 8414 Authorization Server Metadata.
6
+ #
7
+ # The document itself is entirely Doorkeeper's. Only URL generation is
8
+ # Spree's problem: Doorkeeper asks for each endpoint by controller name
9
+ # anchored at the application root, and these routes are drawn inside
10
+ # an isolated engine, which cannot generate from such a name. The
11
+ # injected resolver answers from the engine's own router instead.
12
+ class ServerMetadataController < ::Doorkeeper::MetadataController
13
+ private
14
+
15
+ def metadata_response
16
+ @metadata_response ||= ::Doorkeeper::OAuth::MetadataResponse.new(
17
+ request.base_url,
18
+ ->(**arguments) { resolve_endpoint(**arguments) }
19
+ )
20
+ end
21
+
22
+ # Doorkeeper hands over a controller name anchored at the
23
+ # application root (`/api/v3/…`), while the engine's router knows
24
+ # its own controllers by their engine-qualified names
25
+ # (`spree/api/v3/…`). Doorkeeper's own controllers are already
26
+ # absolute and generate from the application router unchanged.
27
+ def resolve_endpoint(**arguments)
28
+ # Doorkeeper prepends a slash to whatever the route mapping
29
+ # held, so a name configured as "/doorkeeper/tokens" arrives
30
+ # doubled.
31
+ name = arguments[:controller].to_s.sub(%r{\A/+}, '')
32
+
33
+ if name.start_with?('doorkeeper/')
34
+ return Spree::Core::Engine.routes.url_for(
35
+ **arguments.except(:controller),
36
+ controller: "/#{name}",
37
+ only_path: false,
38
+ host: request.host,
39
+ protocol: request.protocol,
40
+ port: request.optional_port
41
+ )
42
+ end
43
+
44
+ Spree::Core::Engine.routes.url_for(
45
+ **arguments.except(:controller),
46
+ controller: "spree/#{name}",
47
+ only_path: false,
48
+ host: request.host,
49
+ protocol: request.protocol,
50
+ port: request.optional_port
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end