spree_api 5.4.3 → 5.5.0.rc1

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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +19 -0
  3. data/app/controllers/concerns/spree/api/v3/admin/auth_cookies.rb +62 -0
  4. data/app/controllers/concerns/spree/api/v3/admin/subclassed_resource.rb +149 -0
  5. data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +54 -0
  6. data/app/controllers/concerns/spree/api/v3/bulk_operations.rb +103 -0
  7. data/app/controllers/concerns/spree/api/v3/channel_resolution.rb +60 -0
  8. data/app/controllers/concerns/spree/api/v3/error_handler.rb +4 -0
  9. data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +84 -0
  10. data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +88 -0
  11. data/app/controllers/concerns/spree/api/v3/store/search_provider_support.rb +35 -1
  12. data/app/controllers/spree/api/v3/admin/admin_users_controller.rb +97 -0
  13. data/app/controllers/spree/api/v3/admin/allowed_origins_controller.rb +25 -0
  14. data/app/controllers/spree/api/v3/admin/api_keys_controller.rb +55 -0
  15. data/app/controllers/spree/api/v3/admin/auth_controller.rb +134 -0
  16. data/app/controllers/spree/api/v3/admin/base_controller.rb +3 -17
  17. data/app/controllers/spree/api/v3/admin/categories_controller.rb +25 -0
  18. data/app/controllers/spree/api/v3/admin/channels_controller.rb +65 -0
  19. data/app/controllers/spree/api/v3/admin/countries_controller.rb +38 -0
  20. data/app/controllers/spree/api/v3/admin/coupon_codes_controller.rb +33 -0
  21. data/app/controllers/spree/api/v3/admin/custom_field_definitions_controller.rb +34 -0
  22. data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +108 -0
  23. data/app/controllers/spree/api/v3/admin/customer_groups_controller.rb +31 -0
  24. data/app/controllers/spree/api/v3/admin/customers/addresses_controller.rb +88 -0
  25. data/app/controllers/spree/api/v3/admin/customers/credit_cards_controller.rb +31 -0
  26. data/app/controllers/spree/api/v3/admin/customers/store_credits_controller.rb +93 -0
  27. data/app/controllers/spree/api/v3/admin/customers_controller.rb +119 -0
  28. data/app/controllers/spree/api/v3/admin/dashboard_controller.rb +44 -0
  29. data/app/controllers/spree/api/v3/admin/direct_uploads_controller.rb +40 -0
  30. data/app/controllers/spree/api/v3/admin/exports_controller.rb +89 -0
  31. data/app/controllers/spree/api/v3/admin/gift_card_batches_controller.rb +31 -0
  32. data/app/controllers/spree/api/v3/admin/gift_cards_controller.rb +33 -0
  33. data/app/controllers/spree/api/v3/admin/invitation_acceptances_controller.rb +138 -0
  34. data/app/controllers/spree/api/v3/admin/invitations_controller.rb +70 -0
  35. data/app/controllers/spree/api/v3/admin/markets_controller.rb +42 -0
  36. data/app/controllers/spree/api/v3/admin/me_controller.rb +69 -0
  37. data/app/controllers/spree/api/v3/admin/media_controller.rb +119 -0
  38. data/app/controllers/spree/api/v3/admin/option_types_controller.rb +34 -0
  39. data/app/controllers/spree/api/v3/admin/orders/adjustments_controller.rb +27 -0
  40. data/app/controllers/spree/api/v3/admin/orders/base_controller.rb +26 -0
  41. data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +104 -0
  42. data/app/controllers/spree/api/v3/admin/orders/gift_cards_controller.rb +79 -0
  43. data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +92 -0
  44. data/app/controllers/spree/api/v3/admin/orders/payments_controller.rb +90 -0
  45. data/app/controllers/spree/api/v3/admin/orders/refunds_controller.rb +53 -0
  46. data/app/controllers/spree/api/v3/admin/orders/store_credits_controller.rb +59 -0
  47. data/app/controllers/spree/api/v3/admin/orders_controller.rb +190 -0
  48. data/app/controllers/spree/api/v3/admin/payment_methods_controller.rb +73 -0
  49. data/app/controllers/spree/api/v3/admin/price_lists_controller.rb +156 -0
  50. data/app/controllers/spree/api/v3/admin/prices_controller.rb +129 -0
  51. data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +48 -0
  52. data/app/controllers/spree/api/v3/admin/products_controller.rb +237 -0
  53. data/app/controllers/spree/api/v3/admin/promotion_actions_controller.rb +78 -0
  54. data/app/controllers/spree/api/v3/admin/promotion_rules_controller.rb +56 -0
  55. data/app/controllers/spree/api/v3/admin/promotions_controller.rb +78 -0
  56. data/app/controllers/spree/api/v3/admin/resource_controller.rb +29 -11
  57. data/app/controllers/spree/api/v3/admin/roles_controller.rb +29 -0
  58. data/app/controllers/spree/api/v3/admin/stock_items_controller.rb +35 -0
  59. data/app/controllers/spree/api/v3/admin/stock_locations_controller.rb +36 -0
  60. data/app/controllers/spree/api/v3/admin/stock_reservations_controller.rb +29 -0
  61. data/app/controllers/spree/api/v3/admin/stock_transfers_controller.rb +75 -0
  62. data/app/controllers/spree/api/v3/admin/store_controller.rb +53 -0
  63. data/app/controllers/spree/api/v3/admin/store_credit_categories_controller.rb +21 -0
  64. data/app/controllers/spree/api/v3/admin/tags_controller.rb +51 -0
  65. data/app/controllers/spree/api/v3/admin/tax_categories_controller.rb +21 -0
  66. data/app/controllers/spree/api/v3/admin/variants_controller.rb +33 -0
  67. data/app/controllers/spree/api/v3/admin/webhook_deliveries_controller.rb +49 -0
  68. data/app/controllers/spree/api/v3/admin/webhook_endpoints_controller.rb +75 -0
  69. data/app/controllers/spree/api/v3/resource_controller.rb +90 -8
  70. data/app/controllers/spree/api/v3/store/auth_controller.rb +8 -28
  71. data/app/controllers/spree/api/v3/store/base_controller.rb +6 -0
  72. data/app/controllers/spree/api/v3/store/carts_controller.rb +1 -0
  73. data/app/controllers/spree/api/v3/store/customers_controller.rb +6 -0
  74. data/app/controllers/spree/api/v3/store/newsletter_subscribers_controller.rb +77 -0
  75. data/app/controllers/spree/api/v3/store/products/filters_controller.rb +2 -2
  76. data/app/controllers/spree/api/v3/store/products_controller.rb +3 -3
  77. data/app/controllers/spree/api/v3/store/resource_controller.rb +10 -2
  78. data/app/jobs/spree/webhook_delivery_job.rb +5 -0
  79. data/app/models/spree/api_key_ability.rb +16 -0
  80. data/app/serializers/spree/api/v3/admin/address_serializer.rb +2 -6
  81. data/app/serializers/spree/api/v3/admin/adjustment_serializer.rb +3 -15
  82. data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +19 -3
  83. data/app/serializers/spree/api/v3/admin/allowed_origin_serializer.rb +2 -6
  84. data/app/serializers/spree/api/v3/admin/api_key_serializer.rb +42 -0
  85. data/app/serializers/spree/api/v3/admin/category_serializer.rb +4 -3
  86. data/app/serializers/spree/api/v3/admin/channel_serializer.rb +15 -0
  87. data/app/serializers/spree/api/v3/admin/country_serializer.rb +1 -1
  88. data/app/serializers/spree/api/v3/admin/coupon_code_serializer.rb +30 -0
  89. data/app/serializers/spree/api/v3/admin/credit_card_serializer.rb +4 -2
  90. data/app/serializers/spree/api/v3/admin/custom_field_definition_serializer.rb +21 -0
  91. data/app/serializers/spree/api/v3/admin/custom_field_serializer.rb +8 -3
  92. data/app/serializers/spree/api/v3/admin/customer_group_serializer.rb +27 -0
  93. data/app/serializers/spree/api/v3/admin/customer_serializer.rb +58 -2
  94. data/app/serializers/spree/api/v3/admin/dashboard_analytics_serializer.rb +143 -0
  95. data/app/serializers/spree/api/v3/admin/export_serializer.rb +40 -0
  96. data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -6
  97. data/app/serializers/spree/api/v3/admin/{asset_serializer.rb → gift_card_batch_serializer.rb} +1 -1
  98. data/app/serializers/spree/api/v3/admin/gift_card_serializer.rb +39 -4
  99. data/app/serializers/spree/api/v3/admin/invitation_serializer.rb +64 -0
  100. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +4 -16
  101. data/app/serializers/spree/api/v3/admin/media_serializer.rb +24 -2
  102. data/app/serializers/spree/api/v3/admin/option_type_serializer.rb +4 -1
  103. data/app/serializers/spree/api/v3/admin/option_value_serializer.rb +4 -1
  104. data/app/serializers/spree/api/v3/admin/order_serializer.rb +21 -6
  105. data/app/serializers/spree/api/v3/admin/payment_method_serializer.rb +11 -2
  106. data/app/serializers/spree/api/v3/admin/payment_serializer.rb +2 -6
  107. data/app/serializers/spree/api/v3/admin/payment_source_serializer.rb +4 -1
  108. data/app/serializers/spree/api/v3/admin/price_list_serializer.rb +51 -0
  109. data/app/serializers/spree/api/v3/admin/price_rule_serializer.rb +55 -0
  110. data/app/serializers/spree/api/v3/admin/price_serializer.rb +4 -0
  111. data/app/serializers/spree/api/v3/admin/product_publication_serializer.rb +11 -0
  112. data/app/serializers/spree/api/v3/admin/product_serializer.rb +34 -10
  113. data/app/serializers/spree/api/v3/admin/promotion_action_serializer.rb +71 -0
  114. data/app/serializers/spree/api/v3/admin/promotion_rule_serializer.rb +85 -0
  115. data/app/serializers/spree/api/v3/admin/promotion_serializer.rb +41 -0
  116. data/app/serializers/spree/api/v3/admin/refund_serializer.rb +4 -2
  117. data/app/serializers/spree/api/v3/admin/role_serializer.rb +17 -0
  118. data/app/serializers/spree/api/v3/admin/stock_item_serializer.rb +16 -1
  119. data/app/serializers/spree/api/v3/admin/stock_location_serializer.rb +11 -2
  120. data/app/serializers/spree/api/v3/admin/stock_reservation_serializer.rb +46 -0
  121. data/app/serializers/spree/api/v3/admin/stock_transfer_serializer.rb +37 -0
  122. data/app/serializers/spree/api/v3/admin/store_credit_category_serializer.rb +19 -0
  123. data/app/serializers/spree/api/v3/admin/store_credit_serializer.rb +11 -5
  124. data/app/serializers/spree/api/v3/admin/store_serializer.rb +55 -0
  125. data/app/serializers/spree/api/v3/admin/tax_category_serializer.rb +4 -2
  126. data/app/serializers/spree/api/v3/admin/variant_serializer.rb +37 -6
  127. data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +45 -0
  128. data/app/serializers/spree/api/v3/admin/webhook_endpoint_serializer.rb +69 -0
  129. data/app/serializers/spree/api/v3/channel_serializer.rb +14 -0
  130. data/app/serializers/spree/api/v3/custom_field_serializer.rb +9 -10
  131. data/app/serializers/spree/api/v3/customer_serializer.rb +5 -0
  132. data/app/serializers/spree/api/v3/market_serializer.rb +2 -1
  133. data/app/serializers/spree/api/v3/media_serializer.rb +8 -6
  134. data/app/serializers/spree/api/v3/order_serializer.rb +6 -1
  135. data/app/serializers/spree/api/v3/payment_method_serializer.rb +11 -2
  136. data/app/serializers/spree/api/v3/product_publication_serializer.rb +22 -0
  137. data/app/serializers/spree/api/v3/product_serializer.rb +6 -1
  138. data/app/serializers/spree/api/v3/stock_reservation_serializer.rb +10 -0
  139. data/config/locales/en.yml +2 -0
  140. data/config/routes.rb +235 -1
  141. data/lib/spree/api/configuration.rb +2 -2
  142. data/lib/spree/api/dependencies.rb +25 -1
  143. data/lib/spree/api/openapi/path_sorter.rb +126 -0
  144. data/lib/spree/api/openapi/schema_helper.rb +185 -6
  145. metadata +96 -8
  146. data/app/serializers/spree/api/v3/admin/shipping_category_serializer.rb +0 -14
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12eb41b74e6dc485f5b8b939b9fe5bc0759c4e3416800914c0463edf9d495fbb
4
- data.tar.gz: 502b4f8356ad84e006805bd49e0148c865c3291e83ff04896310de9e87723f13
3
+ metadata.gz: 34c425ef2a24c388679efdce7af49e3a00df8ec545d54571d11623b35ca0a130
4
+ data.tar.gz: eda87fa654dbf4789c82ea441a4ddcf66971d91323904ad71ca68ef0d30292a5
5
5
  SHA512:
6
- metadata.gz: 3b21d1c061dc754dca6aef069c6f1133015d2a2396bcfbe109b220951a1fcb7a5283f0a7bcfbc753e69ddce7293c2172086fa39fdba93860934e6862b1924120
7
- data.tar.gz: 430ad9042490566c24d1a3f294789c40679c09c7eb60bfd28e4409784be1e16d126ecb7f580f59d5d4fca7365042d00082c7ce8079c9c545c60d988fcf913e33
6
+ metadata.gz: 2c89bedcfd4322921097213e93c2cfb8308c0dbbd960b848c21d625d42b85cf6f845311b56dd02b06e6c392fb38680796cfa1f8e74dc52d135cb870e13c062ac
7
+ data.tar.gz: f55d6b06ad6db697192f6b1678833062c3fbb3ad05d429c523a5dc06896b87fa2c8ab2b66300041eb8bf07befb2b2bc42eda2f569cb6f8760984c5626a6ea2d6
data/Rakefile CHANGED
@@ -27,6 +27,25 @@ namespace :rswag do
27
27
  ENV['OPENAPI'] = 'true'
28
28
  t.rspec_opts = ['--format Rswag::Specs::SwaggerFormatter', '--order defined']
29
29
  end
30
+
31
+ # rswag emits paths in spec-load order (alphabetical by filename), which doesn't
32
+ # match the curated order in swagger_helper.rb's `tags:` array. Mintlify groups
33
+ # sidebar sections by first-tag-appearance in `paths:`, so reorder paths here
34
+ # so the sidebar follows the `tags:` array.
35
+ Rake::Task['rswag:specs:swaggerize'].enhance do
36
+ $LOAD_PATH.unshift(File.expand_path('lib', __dir__))
37
+ require 'spree/api/openapi/path_sorter'
38
+
39
+ docs = File.expand_path('../../docs/api-reference', __dir__)
40
+ %w[admin.yaml store.yaml].each do |name|
41
+ path = File.join(docs, name)
42
+ next unless File.exist?(path)
43
+
44
+ if Spree::Api::OpenAPI::PathSorter.sort_file!(path)
45
+ puts "Reordered paths by tag → #{path}"
46
+ end
47
+ end
48
+ end
30
49
  end
31
50
  end
32
51
 
@@ -0,0 +1,62 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Cookie-based delivery for admin refresh tokens.
6
+ #
7
+ # Refresh token: HttpOnly signed cookie at /api/v3/admin/auth — invisible to JS,
8
+ # tamper-evident via Rails' cookie signing.
9
+ #
10
+ # CSRF protection:
11
+ # We deliberately do NOT use a CSRF token here. The threat model is fully
12
+ # covered by the combination of:
13
+ # - SameSite=Lax (dev) / SameSite=None; Secure (prod) on the refresh cookie
14
+ # - Spree::AllowedOrigin allowlist enforced via Rack::Cors with credentials: true
15
+ # - CORS preflight blocking cross-origin requests from non-allowlisted Origins
16
+ # A double-submit CSRF token would only add value if the AllowedOrigin allowlist
17
+ # were misconfigured or if an XSS happened on a different allowlisted origin —
18
+ # both scenarios where a defender's deeper problem outweighs CSRF mitigation.
19
+ # See docs/plans/5.5-admin-auth-cookie-refresh.md for the full reasoning.
20
+ module AuthCookies
21
+ extend ActiveSupport::Concern
22
+
23
+ # ActionController::API drops Cookies — re-include it on the auth controller only.
24
+ # Rest of the admin API stays cookie-free and stateless.
25
+ included do
26
+ include ActionController::Cookies
27
+ end
28
+
29
+ REFRESH_COOKIE_NAME = :spree_admin_refresh_token
30
+ COOKIE_PATH = '/api/v3/admin/auth'.freeze
31
+
32
+ private
33
+
34
+ def set_refresh_cookie(refresh_token)
35
+ cookies.signed[REFRESH_COOKIE_NAME] = base_cookie_attributes.merge(
36
+ value: refresh_token.token,
37
+ expires: refresh_token.expires_at,
38
+ path: COOKIE_PATH,
39
+ httponly: true
40
+ )
41
+ end
42
+
43
+ def clear_refresh_cookie
44
+ cookies.delete(REFRESH_COOKIE_NAME, path: COOKIE_PATH)
45
+ end
46
+
47
+ def refresh_token_from_cookie
48
+ cookies.signed[REFRESH_COOKIE_NAME].presence
49
+ end
50
+
51
+ def base_cookie_attributes
52
+ if Rails.env.production?
53
+ { secure: true, same_site: :none }
54
+ else
55
+ { secure: false, same_site: :lax }
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,149 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Shared `create` / `update` flow for STI parents whose subclass is
6
+ # picked at request time and whose configuration lives in a
7
+ # `preferences` hash (PaymentMethod, PromotionAction, PromotionRule).
8
+ #
9
+ # Including controllers declare:
10
+ #
11
+ # subclassed_via -> { Spree::PaymentMethod.providers },
12
+ # unknown_type_error: 'unknown_payment_method_type'
13
+ #
14
+ # The body picks the subclass against the registry (returns 422 with
15
+ # the configured error code on miss), strips `type`/`preferences`
16
+ # from the permitted attrs, builds/assigns the rest, and routes
17
+ # preference values through the typed `preferred_<name>=` setters
18
+ # so booleans/decimals/etc. get coerced. Unknown preference keys
19
+ # are silently dropped — the schema endpoint is the source of truth
20
+ # for what's settable.
21
+ module SubclassedResource
22
+ extend ActiveSupport::Concern
23
+
24
+ class_methods do
25
+ def subclassed_via(registry, unknown_type_error:)
26
+ @subclass_registry = registry
27
+ @unknown_type_error_code = unknown_type_error
28
+ end
29
+
30
+ def subclass_registry
31
+ @subclass_registry || (superclass.respond_to?(:subclass_registry) ? superclass.subclass_registry : nil)
32
+ end
33
+
34
+ def unknown_type_error_code
35
+ @unknown_type_error_code ||
36
+ (superclass.respond_to?(:unknown_type_error_code) ? superclass.unknown_type_error_code : nil)
37
+ end
38
+ end
39
+
40
+ def create
41
+ klass = resolve_subclass(params[:type])
42
+ return render_unknown_type unless klass
43
+
44
+ permitted = permitted_params_for(klass)
45
+ attrs, preferences, calculator = extract_subclass_params(permitted)
46
+
47
+ @resource = build_subclassed_resource(klass, attrs)
48
+ apply_preferences(@resource, preferences) if preferences.present?
49
+ apply_calculator(@resource, calculator) if calculator.present?
50
+ authorize_resource!(@resource, :create)
51
+
52
+ if @resource.save
53
+ render json: serialize_resource(@resource), status: :created
54
+ else
55
+ render_validation_error(@resource.errors)
56
+ end
57
+ end
58
+
59
+ def update
60
+ @resource = find_resource
61
+ authorize_resource!(@resource, :update)
62
+
63
+ permitted = permitted_params_for(@resource.class)
64
+ attrs, preferences, calculator = extract_subclass_params(permitted)
65
+
66
+ @resource.assign_attributes(attrs)
67
+ apply_preferences(@resource, preferences) if preferences.present?
68
+ apply_calculator(@resource, calculator) if calculator.present?
69
+
70
+ if @resource.save
71
+ render json: serialize_resource(@resource)
72
+ else
73
+ render_validation_error(@resource.errors)
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ # Per-subclass permitted params. Calls `permitted_params` (the base
80
+ # allowlist with `type` + `preferences`) and merges in extras the
81
+ # subclass has declared via `additional_permitted_attributes`.
82
+ # Controllers can override this hook directly if their subclasses
83
+ # expose extras through a different mechanism.
84
+ def permitted_params_for(klass)
85
+ extras = klass.respond_to?(:additional_permitted_attributes) ? klass.additional_permitted_attributes : []
86
+ return permitted_params if extras.blank?
87
+
88
+ params.permit(:type, { preferences: {} }, *extras)
89
+ end
90
+
91
+ # Default build: top-level resource. Nested controllers (actions,
92
+ # rules) override to attach the parent.
93
+ def build_subclassed_resource(klass, attrs)
94
+ klass.new(attrs)
95
+ end
96
+
97
+ def resolve_subclass(type_name)
98
+ return nil if type_name.blank?
99
+
100
+ self.class.subclass_registry.call.find { |klass| klass.api_type == type_name.to_s }
101
+ end
102
+
103
+ def render_unknown_type
104
+ render_error(
105
+ code: self.class.unknown_type_error_code,
106
+ message: Spree.t("api.#{self.class.unknown_type_error_code}",
107
+ default: 'Unknown type'),
108
+ status: :unprocessable_content
109
+ )
110
+ end
111
+
112
+ def apply_preferences(resource, preferences)
113
+ password_keys = resource.password_preference_keys
114
+
115
+ preferences.each do |key, value|
116
+ pref_name = key.to_sym
117
+ next unless resource.has_preference?(pref_name)
118
+ # Round-trip guard: clients fetching a record see masked
119
+ # `:password` values. Submitting the mask back unchanged
120
+ # must NOT overwrite the real secret with `••••cret`.
121
+ next if password_keys.include?(pref_name) && Spree::Preferences::Masking.masked?(value)
122
+
123
+ resource.set_preference(pref_name, value)
124
+ end
125
+ end
126
+
127
+ # Pulls `preferences` and `calculator` out of the permitted
128
+ # params so they can be routed through their typed setters
129
+ # (`set_preference`, `assign_calculator_attributes`) instead
130
+ # of generic `assign_attributes`. The remaining hash is
131
+ # safe to pass through as plain attribute assignments.
132
+ def extract_subclass_params(permitted)
133
+ permitted = permitted.respond_to?(:to_unsafe_h) ? permitted.to_unsafe_h.with_indifferent_access : permitted.with_indifferent_access
134
+ permitted.delete(:type) # subclass is already resolved; don't let it overwrite STI column
135
+ preferences = permitted.delete(:preferences)
136
+ calculator = permitted.delete(:calculator)
137
+ [permitted, preferences, calculator]
138
+ end
139
+
140
+ def apply_calculator(resource, calculator)
141
+ return unless resource.respond_to?(:assign_calculator_attributes)
142
+
143
+ resource.assign_calculator_attributes(calculator)
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,54 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module AdminAuthentication
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ after_action :set_no_store_cache
9
+ end
10
+
11
+ protected
12
+
13
+ # Override JWT audience to require admin tokens
14
+ def expected_audience
15
+ Spree::Api::V3::JwtAuthentication::JWT_AUDIENCE_ADMIN
16
+ end
17
+
18
+ # API-key-only requests bypass CanCanCan: the ScopedAuthorization
19
+ # concern is the authoritative gate (read_/write_ scopes per resource).
20
+ # JWT admin users keep CanCanCan abilities; if both credentials are
21
+ # present, the JWT user wins for permission resolution.
22
+ def current_ability
23
+ return super if current_user
24
+ return super unless @current_api_key
25
+
26
+ @current_ability ||= Spree::ApiKeyAbility.new(ability_options)
27
+ end
28
+
29
+ # Authenticates admin requests via secret API key OR JWT token.
30
+ # Secret keys are checked first (server-to-server integrations),
31
+ # then JWT tokens (admin SPA sessions).
32
+ def authenticate_admin!
33
+ # Try secret API key first
34
+ @current_api_key = Spree::ApiKey.find_by_secret_token(extract_api_key)
35
+ @current_api_key = nil if @current_api_key && @current_api_key.store_id != current_store.id
36
+
37
+ if @current_api_key
38
+ touch_api_key_if_needed(@current_api_key)
39
+ return true
40
+ end
41
+
42
+ # Fall back to JWT authentication
43
+ require_authentication!
44
+ end
45
+
46
+ private
47
+
48
+ def set_no_store_cache
49
+ response.headers['Cache-Control'] = 'private, no-store'
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,103 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ # API-side counterpart to `Spree::Admin::BulkOperationsConcern`. Provides
5
+ # the shared tag bulk actions, the `bulk_collection` relation, and the
6
+ # `after_bulk_tags_change` hook controllers override (e.g. to reindex
7
+ # search + match automatic taxons). Shape mirrors the legacy concern so
8
+ # the two stay easy to keep in sync.
9
+ module BulkOperations
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ # Guards the concern's two actions; host controllers add their own
14
+ # bulk actions to the filter with `before_action :require_ids!, only: [...]`.
15
+ before_action :require_ids!, only: [:bulk_add_tags, :bulk_remove_tags]
16
+ end
17
+
18
+ # POST /api/v3/admin/<resource>/bulk_add_tags
19
+ # Body: { ids: [...], tags: ['summer', 'sale'] }
20
+ def bulk_add_tags
21
+ authorize! :update, model_class
22
+
23
+ Spree::Tags::BulkAdd.call(tag_names: Array(params[:tags]), records: bulk_collection)
24
+ after_bulk_tags_change
25
+
26
+ render json: bulk_tags_response
27
+ end
28
+
29
+ # POST /api/v3/admin/<resource>/bulk_remove_tags
30
+ # Body: { ids: [...], tags: ['summer', 'sale'] }
31
+ def bulk_remove_tags
32
+ authorize! :update, model_class
33
+
34
+ Spree::Tags::BulkRemove.call(tag_names: Array(params[:tags]), records: bulk_collection)
35
+ after_bulk_tags_change
36
+
37
+ render json: bulk_tags_response
38
+ end
39
+
40
+ private
41
+
42
+ # 422s when the caller omits `ids` entirely. An explicit empty array
43
+ # is allowed (= no-op, useful when a UI clears its selection between
44
+ # render and submit); a missing key is treated as a client bug since
45
+ # bulk endpoints would otherwise silently match no rows and the
46
+ # caller couldn't tell the difference from a successful zero-match.
47
+ def require_ids!
48
+ return if params.key?(:ids)
49
+
50
+ render_error(
51
+ code: 'missing_ids',
52
+ message: 'ids is required (send an empty array to no-op).',
53
+ status: :unprocessable_content
54
+ )
55
+ end
56
+
57
+ # Hook for controllers to perform additional work after bulk tag
58
+ # mutations — e.g. enqueueing search reindex jobs, re-matching
59
+ # automatic taxons. Mirrors `Spree::Admin::BulkOperationsConcern`.
60
+ def after_bulk_tags_change
61
+ end
62
+
63
+ # Slim ability-scoped relation targeted by the inbound `ids` param.
64
+ # Cross-store IDs and IDs the current admin can't update are silently
65
+ # dropped before any mutation runs. Mirrors the legacy concern's
66
+ # `bulk_collection` — no eager loads, because bulk endpoints only need
67
+ # `id` + `store_ids` per record.
68
+ def bulk_collection
69
+ @bulk_collection ||= begin
70
+ product_ids = decode_ids(params[:ids])
71
+ model_class.for_store(current_store).accessible_by(current_ability, :update)
72
+ .where(id: product_ids)
73
+ end
74
+ end
75
+
76
+ # Default shape for bulk tag responses; controllers override
77
+ # `bulk_record_count_key` to give the count its resource-named JSON key
78
+ # (e.g. `product_count`, `order_count`). Mirrors how the legacy admin
79
+ # concern delegates response shaping to the host controller.
80
+ def bulk_tags_response
81
+ {
82
+ bulk_record_count_key => bulk_collection.size,
83
+ tag_count: Array(params[:tags]).size
84
+ }
85
+ end
86
+
87
+ def bulk_record_count_key
88
+ :record_count
89
+ end
90
+
91
+ # Maps inbound IDs (a mix of prefixed `prod_…` strings and raw IDs) to
92
+ # raw IDs. Anything that isn't a valid prefixed ID is passed through
93
+ # verbatim, so legacy clients sending raw IDs keep working. Prefix
94
+ # decoding is pure — no DB lookup needed.
95
+ def decode_ids(ids)
96
+ Array(ids).map do |id|
97
+ Spree::PrefixedId.prefixed_id?(id) ? Spree::PrefixedId.decode_prefixed_id(id) : id
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,60 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ # Resolves the active Spree::Channel for an API request and writes it
5
+ # into +Spree::Current.channel+ so models, scopes, and serializers can
6
+ # read channel context without threading it through method args.
7
+ #
8
+ # Resolution order:
9
+ # 1. +X-Spree-Channel+ header value matched against +channels.code+ —
10
+ # or, if it looks like a prefixed ID (+ch_…+), against +channels.id+
11
+ # — scoped to the current store
12
+ # 2. +current_store.default_channel+
13
+ #
14
+ # The concern is a no-op if no channel matches — callers fall back to
15
+ # +Spree::Current.channel+'s store-default behavior.
16
+ module ChannelResolution
17
+ extend ActiveSupport::Concern
18
+
19
+ CHANNEL_HEADER = 'X-Spree-Channel'.freeze
20
+
21
+ included do
22
+ before_action :set_current_channel
23
+ end
24
+
25
+ protected
26
+
27
+ def current_channel
28
+ @current_channel ||= channel_from_header || Spree::Current.channel
29
+ end
30
+
31
+ private
32
+
33
+ # Only write to Spree::Current when the header resolves a specific
34
+ # channel. The store-default fallback is handled lazily by
35
+ # +Spree::Current.channel+ itself, which avoids one query per
36
+ # header-less API request.
37
+ def set_current_channel
38
+ channel = channel_from_header
39
+ Spree::Current.channel = channel if channel
40
+ end
41
+
42
+ def channel_from_header
43
+ value = request.headers[CHANNEL_HEADER].presence
44
+ return nil if value.blank?
45
+ return nil unless current_store
46
+
47
+ scope = current_store.channels.active
48
+ # Accept either a merchant-meaningful +code+ ("pos", "wholesale") or
49
+ # the opaque prefixed ID — mirrors how Store API endpoints accept
50
+ # either slug or prefixed ID (e.g. +products/{slug-or-id}+).
51
+ if Spree::PrefixedId.prefixed_id?(value)
52
+ scope.find_by_prefix_id(value)
53
+ else
54
+ scope.find_by(code: value)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -31,6 +31,7 @@ module Spree
31
31
 
32
32
  # Order errors
33
33
  order_not_found: 'order_not_found',
34
+ order_cannot_complete: 'order_cannot_complete',
34
35
 
35
36
  # Line item errors
36
37
  line_item_not_found: 'line_item_not_found',
@@ -59,6 +60,9 @@ module Spree
59
60
  digital_link_expired: 'digital_link_expired',
60
61
  download_limit_exceeded: 'download_limit_exceeded',
61
62
 
63
+ # Export errors
64
+ export_not_ready: 'export_not_ready',
65
+
62
66
  # Rate limiting errors
63
67
  rate_limit_exceeded: 'rate_limit_exceeded',
64
68
 
@@ -0,0 +1,84 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ # Normalizes flat API v3 JSON params for Rails model consumption:
5
+ #
6
+ # 1. **Prefixed ID resolution** — decodes Stripe-style prefixed IDs (e.g. "prod_86Rf07xd4z")
7
+ # to integer primary keys for any param ending in `_id` or `_ids`.
8
+ #
9
+ # 2. **Nested attributes normalization** — converts flat arrays (e.g. `taxon_rules: [...]`)
10
+ # to Rails `_attributes` format (e.g. `taxon_rules_attributes: [...]`) based on
11
+ # the model's `accepts_nested_attributes_for` declarations.
12
+ #
13
+ # Uses `prepend` so it always wraps `permitted_params` regardless of which
14
+ # controller in the hierarchy defines it — no manual calls needed.
15
+ module ParamsNormalizer
16
+ extend ActiveSupport::Concern
17
+
18
+ private
19
+
20
+ def normalize_params(permitted)
21
+ hash = permitted.to_h.with_indifferent_access
22
+ hash = resolve_prefixed_ids(hash)
23
+ hash = normalize_nested_attributes(hash)
24
+ ActionController::Parameters.new(hash).permit!
25
+ end
26
+
27
+ def resolve_prefixed_ids(hash)
28
+ hash.each_with_object({}.with_indifferent_access) do |(key, value), result|
29
+ result[key] = case
30
+ when key.to_s.end_with?('_id') && prefixed_id?(value)
31
+ decode_prefixed_id(value)
32
+ when key.to_s.end_with?('_ids') && value.is_a?(Array)
33
+ value.map { |v| prefixed_id?(v) ? decode_prefixed_id(v) : v }
34
+ when value.is_a?(Hash)
35
+ resolve_prefixed_ids(value)
36
+ when value.is_a?(Array)
37
+ value.map { |v| v.is_a?(Hash) ? resolve_prefixed_ids(v) : v }
38
+ else
39
+ value
40
+ end
41
+ end
42
+ end
43
+
44
+ def normalize_nested_attributes(hash, klass = model_class)
45
+ return hash unless klass.respond_to?(:nested_attributes_options)
46
+
47
+ nested_keys = klass.nested_attributes_options.keys.map(&:to_s)
48
+ return hash if nested_keys.empty?
49
+
50
+ hash.each_with_object({}.with_indifferent_access) do |(key, value), result|
51
+ key_str = key.to_s
52
+ if nested_keys.include?(key_str) && !key_str.end_with?('_attributes')
53
+ child_class = klass.reflect_on_association(key_str)&.klass
54
+ result["#{key_str}_attributes"] = normalize_nested_values(value, child_class)
55
+ else
56
+ result[key] = value
57
+ end
58
+ end
59
+ end
60
+
61
+ def normalize_nested_values(value, child_class)
62
+ return value unless child_class
63
+
64
+ case value
65
+ when Array
66
+ value.map { |v| v.is_a?(Hash) ? normalize_nested_attributes(v.with_indifferent_access, child_class) : v }
67
+ when Hash
68
+ normalize_nested_attributes(value.with_indifferent_access, child_class)
69
+ else
70
+ value
71
+ end
72
+ end
73
+
74
+ def prefixed_id?(value)
75
+ Spree::PrefixedId.prefixed_id?(value)
76
+ end
77
+
78
+ def decode_prefixed_id(prefixed_id_string)
79
+ Spree::PrefixedId.decode_prefixed_id(prefixed_id_string) || prefixed_id_string
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,88 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ # Per-resource scope check for Admin API requests authenticated via API key.
5
+ # JWT-authenticated admin users bypass this and rely on CanCanCan abilities.
6
+ #
7
+ # Controllers declare their scope:
8
+ #
9
+ # class Spree::Api::V3::Admin::OrdersController < ResourceController
10
+ # scoped_resource :orders
11
+ # end
12
+ #
13
+ # The before_action maps the action to a `read_*` (index/show) or `write_*`
14
+ # (everything else, including custom member actions) scope and verifies the
15
+ # API key carries it.
16
+ #
17
+ # See docs/plans/5.5-admin-api-key-scopes.md.
18
+ module ScopedAuthorization
19
+ extend ActiveSupport::Concern
20
+
21
+ READ_ACTIONS = %w[index show].freeze
22
+
23
+ class MissingScopedResource < StandardError
24
+ def initialize(controller_class)
25
+ super("#{controller_class} must declare `scoped_resource :name` " \
26
+ '(or `skip_scope_check!` for endpoints exempt from scope checks).')
27
+ end
28
+ end
29
+
30
+ class_methods do
31
+ def scoped_resource(name)
32
+ self._scoped_resource = name.to_sym
33
+ self._scope_check_skipped = false
34
+ end
35
+
36
+ # Opt out of scope checks (auth, me, tags, direct uploads, etc).
37
+ def skip_scope_check!
38
+ self._scope_check_skipped = true
39
+ end
40
+ end
41
+
42
+ included do
43
+ class_attribute :_scoped_resource, instance_accessor: false
44
+ class_attribute :_scope_check_skipped, instance_accessor: false, default: false
45
+ before_action :authorize_api_key_scope!
46
+ end
47
+
48
+ private
49
+
50
+ def authorize_api_key_scope!
51
+ return unless current_api_key
52
+ return if self.class._scope_check_skipped
53
+
54
+ resource = scoped_resource_name
55
+ # Fail closed: a controller authenticated by API key MUST declare
56
+ # either `scoped_resource :name`, override `scoped_resource_name`,
57
+ # or `skip_scope_check!`.
58
+ raise MissingScopedResource, self.class unless resource
59
+
60
+ required = "#{action_kind}_#{resource}"
61
+ return if current_api_key.has_scope?(required)
62
+
63
+ render_error(
64
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
65
+ message: "API key lacks scope: #{required}",
66
+ status: :forbidden,
67
+ details: { required_scope: required }
68
+ )
69
+ end
70
+
71
+ # The resource name used in scope strings (`read_<name>` / `write_<name>`).
72
+ # Defaults to the class-level `scoped_resource :name` declaration.
73
+ # Override in controllers that resolve scope at request time (e.g. the
74
+ # nested-on-many-parents `CustomFieldsController` returns the parent's
75
+ # route segment).
76
+ def scoped_resource_name
77
+ self.class._scoped_resource
78
+ end
79
+
80
+ # Override in controllers with non-REST custom actions (e.g. dashboard
81
+ # `analytics` should map to a read).
82
+ def action_kind
83
+ READ_ACTIONS.include?(action_name) ? 'read' : 'write'
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end