spree_api 5.6.0.rc1 → 5.6.0.rc3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 89ddc07a0f4f9334f2ecb79bf2283986e8078e5566d4a7b1c672fadd2f04453d
4
- data.tar.gz: 1db32c6bf66382dc2786d1898aaaf5e6c3e1a04b721404fad423c37cc3e0326b
3
+ metadata.gz: 1029d16c5aff295ae773e0c605d1c2003157c4efcfdc5ac5f4ef6edf073b0d68
4
+ data.tar.gz: 8778b2415f90f19df1f7631ff6df0f4f2508ec8ba7d4116f81f76fecf81dae49
5
5
  SHA512:
6
- metadata.gz: 001b8a751f6cd60f71ee162e4387570f8976bd47a3c6794f6d850ba42fe157c3850a137646fed9f6c22651e9d86bf08bad579f2a6854370442cc9dbec0ae1c3f
7
- data.tar.gz: 6eb41a561383ce8ab469c21c1a341673164fadb3f5e738ccb7b0635bccd7ff56d6d280d30bcd3aa1bea852763b4f13908b96e45b071013736013c759f71f48fe
6
+ metadata.gz: d7f0f2b4eabc6fc302f18b8cdd76d3213360ba927f3a1f5690253e7443baf4778464c1a3c395f1fa29827f1a2acdb66849e6738bc1af7e6fd0f9a910bcf09ecd
7
+ data.tar.gz: 8db65610da6e5ff6bc2dd736050c19d6c9624f64e48ba56b85d0c6bcc935f3ba1a00ff2080643b35cda5c7d05e0c83f3510c75c09926e483ae4b15c93bdc39a5
@@ -10,7 +10,7 @@ module Spree
10
10
  # CSRF protection:
11
11
  # We deliberately do NOT use a CSRF token here. The threat model is fully
12
12
  # covered by the combination of:
13
- # - SameSite=Lax (dev) / SameSite=None; Secure (prod) on the refresh cookie
13
+ # - SameSite=Lax (http) / SameSite=None; Secure (https) on the refresh cookie
14
14
  # - Spree::AllowedOrigin allowlist enforced via Rack::Cors with credentials: true
15
15
  # - CORS preflight blocking cross-origin requests from non-allowlisted Origins
16
16
  # A double-submit CSRF token would only add value if the AllowedOrigin allowlist
@@ -48,8 +48,14 @@ module Spree
48
48
  cookies.signed[REFRESH_COOKIE_NAME].presence
49
49
  end
50
50
 
51
+ # Keyed off the request scheme, not the Rails env: Rails silently refuses to
52
+ # write a Secure cookie on a non-SSL request, so a production app served over
53
+ # plain http (local Docker image, http-only intranet) would never get the
54
+ # refresh cookie at all. SameSite=None requires Secure, so http falls back to
55
+ # Lax — fine for same-site setups (ports don't factor into site identity);
56
+ # cross-site dashboards need https anyway.
51
57
  def base_cookie_attributes
52
- if Rails.env.production?
58
+ if request.ssl?
53
59
  { secure: true, same_site: :none }
54
60
  else
55
61
  { secure: false, same_site: :lax }
@@ -7,17 +7,19 @@ module Spree
7
7
 
8
8
  # POST /api/v3/admin/customers/:customer_id/addresses
9
9
  def create
10
- @resource = @parent.addresses.new(address_attrs)
11
- authorize_resource!(@resource, :create)
10
+ authorize_resource!(Spree::Address.new(user_id: @parent.id), :create)
12
11
 
13
- ApplicationRecord.transaction do
14
- if @resource.save
15
- apply_default_flags(@resource)
16
- render json: serialize_resource(@resource.reload), status: :created
17
- else
18
- render_validation_error(@resource.errors)
19
- raise ActiveRecord::Rollback
20
- end
12
+ result = Spree.address_create_service.call(
13
+ address_params: address_attrs,
14
+ user: @parent,
15
+ default_billing: default_billing_flag,
16
+ default_shipping: default_shipping_flag
17
+ )
18
+
19
+ if result.success?
20
+ render json: serialize_resource(result.value), status: :created
21
+ else
22
+ render_validation_error(result.value.errors)
21
23
  end
22
24
  end
23
25
 
@@ -25,14 +27,17 @@ module Spree
25
27
  def update
26
28
  authorize_resource!(@resource)
27
29
 
28
- ApplicationRecord.transaction do
29
- if @resource.update(address_attrs)
30
- apply_default_flags(@resource)
31
- render json: serialize_resource(@resource.reload)
32
- else
33
- render_validation_error(@resource.errors)
34
- raise ActiveRecord::Rollback
35
- end
30
+ result = Spree.address_update_service.call(
31
+ address: @resource,
32
+ address_params: address_attrs,
33
+ default_billing: default_billing_flag,
34
+ default_shipping: default_shipping_flag
35
+ )
36
+
37
+ if result.success?
38
+ render json: serialize_resource(result.value)
39
+ else
40
+ render_validation_error(result.value.errors)
36
41
  end
37
42
  end
38
43
 
@@ -69,11 +74,12 @@ module Spree
69
74
  )
70
75
  end
71
76
 
72
- def apply_default_flags(address)
73
- updates = {}
74
- updates[:bill_address_id] = address.id if ActiveModel::Type::Boolean.new.cast(params[:is_default_billing])
75
- updates[:ship_address_id] = address.id if ActiveModel::Type::Boolean.new.cast(params[:is_default_shipping])
76
- @parent.update!(updates) if updates.any?
77
+ def default_billing_flag
78
+ ActiveModel::Type::Boolean.new.cast(params[:is_default_billing])
79
+ end
80
+
81
+ def default_shipping_flag
82
+ ActiveModel::Type::Boolean.new.cast(params[:is_default_shipping])
77
83
  end
78
84
  end
79
85
  end
@@ -83,10 +83,15 @@ module Spree
83
83
  end
84
84
 
85
85
  # PATCH /api/v3/store/carts/:id/associate
86
- # Associates a guest cart with the currently authenticated user
87
- # Requires: JWT authentication + cart ID in URL
86
+ # Associates a guest cart with the currently authenticated user.
87
+ # Requires: JWT authentication + cart ID in URL + possession of the
88
+ # cart's token (x-spree-token). The token is the credential that
89
+ # authorizes claiming the cart, so it is required even when the cart
90
+ # is already the caller's own.
88
91
  def associate
89
92
  @cart = find_cart_for_association
93
+ authorize!(:update, @cart, cart_token)
94
+ require_cart_token!
90
95
 
91
96
  result = Spree.cart_associate_service.call(guest_order: @cart, user: current_user, guest_only: true)
92
97
 
@@ -177,6 +182,14 @@ module Spree
177
182
  def find_cart_for_association
178
183
  current_store.carts.where(user: [nil, current_user]).find_by_prefix_id!(params[:id])
179
184
  end
185
+
186
+ # Claiming a cart requires presenting its token, even when the caller
187
+ # already owns it — the token, not ownership, is the claim credential.
188
+ def require_cart_token!
189
+ valid = cart_token.present? && cart_token == @cart.token
190
+
191
+ raise CanCan::AccessDenied.new(nil, :update, @cart) unless valid
192
+ end
180
193
  end
181
194
  end
182
195
  end
@@ -35,6 +35,14 @@ module Spree
35
35
  end
36
36
  end
37
37
 
38
+ # DELETE /api/v3/store/customers/me/addresses/:id
39
+ def destroy
40
+ @resource.destroy!
41
+ head :no_content
42
+ rescue ActiveRecord::RecordNotDestroyed => e
43
+ render_validation_error(e.record.errors.presence || e.message)
44
+ end
45
+
38
46
  protected
39
47
 
40
48
  def set_parent
@@ -7,7 +7,7 @@ module Spree
7
7
  rate_limit to: Spree::Api::Config[:rate_limit_register],
8
8
  within: Spree::Api::Config[:rate_limit_window].seconds,
9
9
  store: Rails.cache,
10
- only: [:create, :verify],
10
+ only: [:create, :verify, :request_unsubscribe],
11
11
  with: RATE_LIMIT_RESPONSE
12
12
 
13
13
  # POST /api/v3/store/newsletter_subscribers
@@ -53,6 +53,57 @@ module Spree
53
53
  render json: serialize_resource(subscriber)
54
54
  end
55
55
 
56
+ # DELETE /api/v3/store/newsletter_subscribers/:id
57
+ # Accepts either of:
58
+ # - Authorization: Bearer <jwt> — logged-in customer destroying their own subscription
59
+ # - ?token=<unsubscribe-token> — bearer from `subscriber.generate_token_for(:unsubscribe)`,
60
+ # typically clicked from an unsubscribe email
61
+ def destroy
62
+ subscriber = find_owned_subscriber || find_subscriber_by_unsubscribe_token
63
+
64
+ if subscriber.blank?
65
+ return render_error(
66
+ code: ERROR_CODES[:invalid_token],
67
+ message: Spree.t(:newsletter_unsubscribe_token_invalid, scope: :api),
68
+ status: :unprocessable_content
69
+ )
70
+ end
71
+
72
+ linked_user = subscriber.user
73
+ subscriber.destroy!
74
+
75
+ # Keep accepts_email_marketing in sync, but only when no subscriptions remain.
76
+ if linked_user&.accepts_email_marketing? && Spree::NewsletterSubscriber.where(user_id: linked_user.id).none?
77
+ linked_user.update(accepts_email_marketing: false)
78
+ end
79
+
80
+ head :no_content
81
+ end
82
+
83
+ # POST /api/v3/store/newsletter_subscribers/request_unsubscribe
84
+ # Publishes a `newsletter_subscriber.unsubscribe_requested` event carrying the unsubscribe token.
85
+ # Always returns 202 to prevent email enumeration.
86
+ def request_unsubscribe
87
+ subscriber = Spree::NewsletterSubscriber.for_store(current_store).find_by(email: params[:email])
88
+
89
+ if subscriber
90
+ payload = {
91
+ id: subscriber.prefixed_id,
92
+ email: subscriber.email,
93
+ unsubscribe_token: subscriber.generate_token_for(:unsubscribe),
94
+ store_id: current_store.prefixed_id,
95
+ customer_id: subscriber.user&.prefixed_id
96
+ }
97
+
98
+ redirect_url = validated_redirect_url
99
+ payload[:redirect_url] = redirect_url if redirect_url.present?
100
+
101
+ subscriber.publish_event('newsletter_subscriber.unsubscribe_requested', payload)
102
+ end
103
+
104
+ head :accepted
105
+ end
106
+
56
107
  protected
57
108
 
58
109
  def serializer_class
@@ -61,6 +112,22 @@ module Spree
61
112
 
62
113
  private
63
114
 
115
+ def find_owned_subscriber
116
+ return unless current_user
117
+
118
+ Spree::NewsletterSubscriber.
119
+ for_store(current_store).
120
+ accessible_by(current_ability, :destroy).
121
+ find_by_prefix_id(params[:id])
122
+ end
123
+
124
+ def find_subscriber_by_unsubscribe_token
125
+ return if params[:token].blank?
126
+
127
+ subscriber = Spree::NewsletterSubscriber.for_store(current_store).find_by_token_for(:unsubscribe, params[:token])
128
+ subscriber if subscriber&.prefixed_id == params[:id]
129
+ end
130
+
64
131
  # Drop redirect_url when it isn't in the store's allow-list — secure-by-default,
65
132
  # mirrors password_resets. Returning nil omits it from the webhook payload rather
66
133
  # than rejecting the request, so callers can't probe the allow-list via 4xx errors.
@@ -85,6 +85,16 @@ module Spree
85
85
  one :bill_address, key: :default_billing_address, resource: proc { Spree.api.admin_address_serializer }, if: proc { expand?('default_billing_address') }
86
86
  one :ship_address, key: :default_shipping_address, resource: proc { Spree.api.admin_address_serializer }, if: proc { expand?('default_shipping_address') }
87
87
 
88
+ # Override the inherited always-on store association to gate it behind
89
+ # expand? — the admin customers index serializes many rows and an
90
+ # always-on association would fire one query per row.
91
+ one :newsletter_subscriber,
92
+ resource: proc { Spree.api.admin_newsletter_subscriber_serializer },
93
+ if: proc { expand?('newsletter_subscriber') } do |user, params|
94
+ store = params&.dig(:store) || Spree::Current.store
95
+ user.newsletter_subscriber(store)
96
+ end
97
+
88
98
  many :orders,
89
99
  resource: proc { Spree.api.admin_order_serializer },
90
100
  if: proc { expand?('orders') }
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Api
5
+ module V3
6
+ module Admin
7
+ # Admin API Newsletter Subscriber Serializer
8
+ # Back-office surface, nested under the admin customer serializer. Extends the
9
+ # customer-facing serializer so public fields stay in sync.
10
+ class NewsletterSubscriberSerializer < V3::NewsletterSubscriberSerializer
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -8,7 +8,8 @@ module Spree
8
8
  full_name: :string,
9
9
  phone: [:string, nullable: true], accepts_email_marketing: :boolean,
10
10
  available_store_credit_total: :string, display_available_store_credit_total: :string,
11
- default_billing_address: { nullable: true }, default_shipping_address: { nullable: true }
11
+ default_billing_address: { nullable: true }, default_shipping_address: { nullable: true },
12
+ newsletter_subscriber: { nullable: true }
12
13
 
13
14
  attributes :email, :first_name, :last_name, :phone, :accepts_email_marketing
14
15
 
@@ -31,6 +32,11 @@ module Spree
31
32
  many :addresses, resource: proc { Spree.api.address_serializer }
32
33
  one :bill_address, key: :default_billing_address, resource: proc { Spree.api.address_serializer }
33
34
  one :ship_address, key: :default_shipping_address, resource: proc { Spree.api.address_serializer }
35
+
36
+ one :newsletter_subscriber, resource: proc { Spree.api.newsletter_subscriber_serializer } do |user, params|
37
+ store = params&.dig(:store) || Spree::Current.store
38
+ user.newsletter_subscriber(store)
39
+ end
34
40
  end
35
41
  end
36
42
  end
@@ -48,7 +48,7 @@ module Spree
48
48
  attribute :description do |product|
49
49
  next if product.description.blank?
50
50
 
51
- Nokogiri::HTML.fragment(product.description).text.squish
51
+ Spree::RichTextHelper.to_plain_text(product.description)
52
52
  end
53
53
 
54
54
  attribute :description_html do |product|
@@ -13,6 +13,7 @@ en:
13
13
  guest_checkout_not_allowed: You must be signed in to complete checkout.
14
14
  storefront_login_required: Authentication required to access this store.
15
15
  negative_quantity: quantity is negative
16
+ newsletter_unsubscribe_token_invalid: Newsletter unsubscribe token is invalid or has expired.
16
17
  newsletter_verification_token_invalid: Newsletter verification token is invalid or has expired.
17
18
  order:
18
19
  could_not_transition: The order could not be transitioned. Please fix the errors and try again.
data/config/routes.rb CHANGED
@@ -59,10 +59,15 @@ Spree::Core::Engine.add_routes do
59
59
  # Customers
60
60
  resources :customers, only: [:create]
61
61
 
62
- # Newsletter Subscriptions (guest-accessible: subscribe + verify by token)
63
- resources :newsletter_subscribers, only: [:create] do
62
+ # Newsletter Subscriptions
63
+ # - create + verify + request_unsubscribe are guest-accessible
64
+ # - destroy accepts either an unsubscribe token (from email links) or JWT auth
65
+ # A signed-in customer reads their own subscription (and its id) off the
66
+ # `newsletter_subscriber` association on GET /customers/me.
67
+ resources :newsletter_subscribers, only: [:create, :destroy] do
64
68
  collection do
65
69
  post :verify
70
+ post :request_unsubscribe
66
71
  end
67
72
  end
68
73
 
@@ -172,6 +172,7 @@ module Spree
172
172
  admin_state_serializer: 'Spree::Api::V3::Admin::StateSerializer',
173
173
  admin_discount_serializer: 'Spree::Api::V3::Admin::DiscountSerializer',
174
174
  admin_customer_serializer: 'Spree::Api::V3::Admin::CustomerSerializer',
175
+ admin_newsletter_subscriber_serializer: 'Spree::Api::V3::Admin::NewsletterSubscriberSerializer',
175
176
  admin_order_serializer: 'Spree::Api::V3::Admin::OrderSerializer',
176
177
  admin_product_serializer: 'Spree::Api::V3::Admin::ProductSerializer',
177
178
  admin_variant_serializer: 'Spree::Api::V3::Admin::VariantSerializer',
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.6.0.rc1
4
+ version: 5.6.0.rc3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vendo Connect Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-15 00:00:00.000000000 Z
11
+ date: 2026-07-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rswag-specs
@@ -72,14 +72,14 @@ dependencies:
72
72
  requirements:
73
73
  - - '='
74
74
  - !ruby/object:Gem::Version
75
- version: 5.6.0.rc1
75
+ version: 5.6.0.rc3
76
76
  type: :runtime
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
80
  - - '='
81
81
  - !ruby/object:Gem::Version
82
- version: 5.6.0.rc1
82
+ version: 5.6.0.rc3
83
83
  description: Spree's API
84
84
  email:
85
85
  - hello@spreecommerce.org
@@ -248,6 +248,7 @@ files:
248
248
  - app/serializers/spree/api/v3/admin/line_item_serializer.rb
249
249
  - app/serializers/spree/api/v3/admin/market_serializer.rb
250
250
  - app/serializers/spree/api/v3/admin/media_serializer.rb
251
+ - app/serializers/spree/api/v3/admin/newsletter_subscriber_serializer.rb
251
252
  - app/serializers/spree/api/v3/admin/option_type_serializer.rb
252
253
  - app/serializers/spree/api/v3/admin/option_value_serializer.rb
253
254
  - app/serializers/spree/api/v3/admin/order_serializer.rb
@@ -379,9 +380,9 @@ licenses:
379
380
  - BSD-3-Clause
380
381
  metadata:
381
382
  bug_tracker_uri: https://github.com/spree/spree/issues
382
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.6.0.rc1
383
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.6.0.rc3
383
384
  documentation_uri: https://docs.spreecommerce.org/
384
- source_code_uri: https://github.com/spree/spree/tree/v5.6.0.rc1
385
+ source_code_uri: https://github.com/spree/spree/tree/v5.6.0.rc3
385
386
  post_install_message:
386
387
  rdoc_options: []
387
388
  require_paths: