spree_api 6.0.0.rc2 → 6.0.0.rc4
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 +4 -4
- data/app/controllers/concerns/spree/api/v3/admin/email_template_lookup.rb +6 -1
- data/app/controllers/concerns/spree/api/v3/admin/reporting_authorization.rb +17 -8
- data/app/controllers/concerns/spree/api/v3/admin/store_context.rb +7 -0
- data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +61 -0
- data/app/controllers/concerns/spree/api/v3/error_handler.rb +49 -0
- data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +76 -7
- data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +60 -3
- data/app/controllers/concerns/spree/api/v3/store/search_provider_support.rb +8 -3
- data/app/controllers/spree/api/v3/admin/companies/tax_exemption_certificates_controller.rb +11 -5
- data/app/controllers/spree/api/v3/admin/concerns/external_references.rb +3 -0
- data/app/controllers/spree/api/v3/admin/exports_controller.rb +15 -3
- data/app/controllers/spree/api/v3/admin/imports_controller.rb +12 -3
- data/app/controllers/spree/api/v3/admin/markets_controller.rb +3 -1
- data/app/controllers/spree/api/v3/admin/me_controller.rb +21 -1
- data/app/controllers/spree/api/v3/admin/oauth/application_tokens_controller.rb +56 -0
- data/app/controllers/spree/api/v3/admin/oauth/applications_controller.rb +55 -0
- data/app/controllers/spree/api/v3/admin/oauth/authorizations_controller.rb +294 -0
- data/app/controllers/spree/api/v3/admin/order_groups_controller.rb +1 -1
- data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +8 -0
- data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +1 -0
- data/app/controllers/spree/api/v3/admin/orders/returns_controller.rb +7 -0
- data/app/controllers/spree/api/v3/admin/orders_controller.rb +9 -2
- data/app/controllers/spree/api/v3/admin/purchase_orders_controller.rb +1 -0
- data/app/controllers/spree/api/v3/admin/reporting_controller.rb +20 -14
- data/app/controllers/spree/api/v3/admin/stock_levels_controller.rb +1 -1
- data/app/controllers/spree/api/v3/admin/tags_controller.rb +8 -5
- data/app/controllers/spree/api/v3/admin/tax_exemption_reasons_controller.rb +58 -0
- data/app/controllers/spree/api/v3/admin/tax_rates_controller.rb +0 -1
- data/app/controllers/spree/api/v3/admin/translations/batches_controller.rb +17 -11
- data/app/controllers/spree/api/v3/oauth/authorizations_controller.rb +61 -0
- data/app/controllers/spree/api/v3/oauth/metadata_controller.rb +57 -0
- data/app/controllers/spree/api/v3/oauth/server_metadata_controller.rb +57 -0
- data/app/controllers/spree/api/v3/resource_controller.rb +1 -0
- data/app/controllers/spree/api/v3/store/carts/store_credits_controller.rb +13 -1
- data/app/controllers/spree/api/v3/store/carts_controller.rb +4 -7
- data/app/request_schemas/spree/api/v3/admin/email_template_preview_request_schema.rb +4 -0
- data/app/request_schemas/spree/api/v3/store/address_request_schema.rb +2 -0
- data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -0
- data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +2 -0
- data/app/serializers/spree/api/v3/admin/oauth_access_token_serializer.rb +62 -0
- data/app/serializers/spree/api/v3/admin/oauth_application_serializer.rb +56 -0
- data/app/serializers/spree/api/v3/admin/oauth_consent_request_serializer.rb +25 -0
- data/app/serializers/spree/api/v3/admin/oauth_protected_resource_serializer.rb +27 -0
- data/app/serializers/spree/api/v3/admin/oauth_redirect_serializer.rb +17 -0
- data/app/serializers/spree/api/v3/admin/payment_capture_event_serializer.rb +16 -0
- data/app/serializers/spree/api/v3/admin/payment_serializer.rb +12 -1
- data/app/serializers/spree/api/v3/admin/price_history_serializer.rb +1 -0
- data/app/serializers/spree/api/v3/admin/price_serializer.rb +2 -0
- data/app/serializers/spree/api/v3/admin/return_serializer.rb +2 -0
- data/app/serializers/spree/api/v3/admin/tax_exemption_certificate_serializer.rb +7 -3
- data/app/serializers/spree/api/v3/admin/tax_exemption_reason_serializer.rb +20 -0
- data/app/serializers/spree/api/v3/admin/tax_rate_serializer.rb +1 -2
- data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +4 -1
- data/app/serializers/spree/api/v3/base_serializer.rb +14 -0
- data/app/serializers/spree/api/v3/cart_serializer.rb +5 -1
- data/app/serializers/spree/api/v3/price_history_serializer.rb +6 -1
- data/app/serializers/spree/api/v3/price_serializer.rb +11 -1
- data/app/serializers/spree/api/v3/seller/price_history_serializer.rb +1 -0
- data/app/serializers/spree/api/v3/seller/price_serializer.rb +2 -0
- data/config/routes.rb +61 -1
- data/lib/spree/api/agent_membership_map.rb +73 -0
- data/lib/spree/api/agent_resource_map.rb +350 -0
- data/lib/spree/api/agent_workflow_endpoints.rb +113 -0
- data/lib/spree/api/agent_write_schemas.rb +119 -0
- data/lib/spree/api/dependencies.rb +4 -3
- data/lib/spree/api/engine.rb +40 -0
- data/lib/spree/api/extension_fields.rb +167 -0
- data/lib/spree/api/oauth.rb +243 -0
- data/lib/spree/api.rb +8 -0
- data/lib/typelizer/open_enums.rb +17 -3
- metadata +26 -6
|
@@ -6,7 +6,6 @@ module Spree
|
|
|
6
6
|
# customer-facing. What the customer sees is the label on a tax line.
|
|
7
7
|
class TaxRateSerializer < V3::BaseSerializer
|
|
8
8
|
typelize name: :string,
|
|
9
|
-
included_in_price: :boolean,
|
|
10
9
|
show_rate_in_label: :boolean,
|
|
11
10
|
tax_category_id: [:string, nullable: true],
|
|
12
11
|
store_id: [:string, nullable: true],
|
|
@@ -15,7 +14,7 @@ module Spree
|
|
|
15
14
|
metadata: ['Record<string, unknown>', nullable: true],
|
|
16
15
|
deleted_at: [:string, nullable: true]
|
|
17
16
|
|
|
18
|
-
attributes :name, :
|
|
17
|
+
attributes :name, :show_rate_in_label, :metadata,
|
|
19
18
|
created_at: :iso8601, updated_at: :iso8601, deleted_at: :iso8601
|
|
20
19
|
|
|
21
20
|
rate_attributes :rate, :rate_percent
|
|
@@ -32,8 +32,11 @@ module Spree
|
|
|
32
32
|
# shipped still hold live credentials in the column. Null when the
|
|
33
33
|
# caller cannot read the record the event is about.
|
|
34
34
|
attribute :payload do |delivery|
|
|
35
|
+
# Fails closed: a reader that passes no gate gets nothing. The
|
|
36
|
+
# payload keeps the record body by design, so a caller holding
|
|
37
|
+
# `read_webhooks` alone must not see customer or payment data.
|
|
35
38
|
visible = params[:payload_visible]
|
|
36
|
-
next nil
|
|
39
|
+
next nil unless visible&.call(delivery)
|
|
37
40
|
|
|
38
41
|
Spree::WebhookPayloadRedaction.split(delivery.payload).first
|
|
39
42
|
end
|
|
@@ -224,6 +224,20 @@ module Spree
|
|
|
224
224
|
|
|
225
225
|
private
|
|
226
226
|
|
|
227
|
+
# Adds the fields extensions registered for this class
|
|
228
|
+
# (Spree.api.extension_fields), read from the registry on every
|
|
229
|
+
# render as Spree.ransack is, so nothing is copied onto the class. A
|
|
230
|
+
# field never replaces an attribute the serializer writes itself.
|
|
231
|
+
def attributes_to_hash(object, hash)
|
|
232
|
+
super
|
|
233
|
+
Spree.api.extension_fields.for(self.class).each do |field|
|
|
234
|
+
next if @_attributes.key?(field.name)
|
|
235
|
+
|
|
236
|
+
set_key_and_attribute_body_from(object, field.name, field.reader, hash)
|
|
237
|
+
end
|
|
238
|
+
hash
|
|
239
|
+
end
|
|
240
|
+
|
|
227
241
|
# Override Alba's fetch_attribute to automatically inject nested expand params
|
|
228
242
|
# into child serializers via nested_params.
|
|
229
243
|
# Uses `key` (the public API name, e.g. :categories) for expand lookup,
|
|
@@ -74,7 +74,11 @@ module Spree
|
|
|
74
74
|
# it is dropped rather than redacted for a gated guest: a requirement
|
|
75
75
|
# with its numbers stripped tells them nothing they can act on.
|
|
76
76
|
attribute :requirements do |order|
|
|
77
|
-
|
|
77
|
+
# 5.6 storefronts matched the delivery requirement by its field, so
|
|
78
|
+
# it keeps the old name until 6.1; `code` already carries the new one.
|
|
79
|
+
requirements = Spree::Checkout::Requirements.new(order).call.map do |requirement|
|
|
80
|
+
requirement[:field] == 'delivery_method' ? requirement.merge(field: 'shipping_method') : requirement
|
|
81
|
+
end
|
|
78
82
|
next requirements unless params[:hide_prices]
|
|
79
83
|
|
|
80
84
|
requirements.reject { |requirement| requirement[:code] == 'order_minimum_not_met' }
|
|
@@ -6,12 +6,17 @@ module Spree
|
|
|
6
6
|
class PriceHistorySerializer < BaseSerializer
|
|
7
7
|
typelize display_amount: display_money_type(:display_amount, nullable: false),
|
|
8
8
|
currency: [:string, enum: Spree::Money::Rounding::CURRENCY_CODES, enum_type_name: 'CurrencyCode'],
|
|
9
|
-
recorded_at: :string
|
|
9
|
+
recorded_at: :string,
|
|
10
|
+
amount_in_cents: [:number, nullable: true, deprecated: 'Use `amount`. Removed in 6.1.']
|
|
10
11
|
|
|
11
12
|
attributes :currency
|
|
12
13
|
|
|
13
14
|
money_attributes :amount, unit_price: true
|
|
14
15
|
|
|
16
|
+
attribute :amount_in_cents do |price_history|
|
|
17
|
+
price_history.amount_in_cents unless params[:hide_prices]
|
|
18
|
+
end
|
|
19
|
+
|
|
15
20
|
attributes :display_amount
|
|
16
21
|
|
|
17
22
|
attribute :recorded_at do |price_history|
|
|
@@ -8,12 +8,22 @@ module Spree
|
|
|
8
8
|
typelize display_amount: display_money_type(:display_amount),
|
|
9
9
|
display_compare_at_amount: display_money_type(:display_compare_at_amount),
|
|
10
10
|
currency: [:string, nullable: true, enum: Spree::Money::Rounding::CURRENCY_CODES, enum_type_name: 'CurrencyCode'],
|
|
11
|
-
price_list_id: [:string, nullable: true]
|
|
11
|
+
price_list_id: [:string, nullable: true],
|
|
12
|
+
amount_in_cents: [:number, nullable: true, deprecated: 'Use `amount`. Removed in 6.1.'],
|
|
13
|
+
compare_at_amount_in_cents: [:number, nullable: true, deprecated: 'Use `compare_at_amount`. Removed in 6.1.']
|
|
12
14
|
|
|
13
15
|
attributes :currency
|
|
14
16
|
|
|
15
17
|
money_attributes :amount, :compare_at_amount, unit_price: true
|
|
16
18
|
|
|
19
|
+
attribute :amount_in_cents do |price|
|
|
20
|
+
price.amount_in_cents unless params[:hide_prices]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attribute :compare_at_amount_in_cents do |price|
|
|
24
|
+
price.compare_at_amount_in_cents unless params[:hide_prices]
|
|
25
|
+
end
|
|
26
|
+
|
|
17
27
|
attribute :display_amount do |price|
|
|
18
28
|
price.display_amount&.to_s
|
|
19
29
|
end
|
data/config/routes.rb
CHANGED
|
@@ -238,6 +238,27 @@ Spree::Core::Engine.add_routes do
|
|
|
238
238
|
post 'auth/password_resets', to: 'password_resets#create'
|
|
239
239
|
patch 'auth/password_resets/:id', to: 'password_resets#update'
|
|
240
240
|
|
|
241
|
+
# OAuth 2.1 consent, read and decided by the dashboard. The admin's
|
|
242
|
+
# own session authorizes it: a secret key may authenticate here but
|
|
243
|
+
# can never grant authority on a person's behalf.
|
|
244
|
+
namespace :oauth do
|
|
245
|
+
get 'authorize', to: 'authorizations#show'
|
|
246
|
+
post 'authorize', to: 'authorizations#create'
|
|
247
|
+
delete 'authorize', to: 'authorizations#destroy'
|
|
248
|
+
|
|
249
|
+
# The OAuth clients registered against this store. Deleting one
|
|
250
|
+
# removes the registration; revoking a connection is the nested
|
|
251
|
+
# `tokens` resource, because that is what revoking deletes.
|
|
252
|
+
#
|
|
253
|
+
# Tokens are plural and addressable: a store registers a client once
|
|
254
|
+
# and every staff member connects to that same registration, so the
|
|
255
|
+
# live tokens are who is connected. Revoking one has to cut off that
|
|
256
|
+
# person and leave their colleagues working.
|
|
257
|
+
resources :applications, except: :show do
|
|
258
|
+
resources :tokens, only: %i[index destroy], controller: 'application_tokens'
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
241
262
|
# Semantic reporting (docs/plans/6.0-analytics-semantic-layer.md)
|
|
242
263
|
namespace :reporting do
|
|
243
264
|
post :query
|
|
@@ -409,11 +430,12 @@ Spree::Core::Engine.add_routes do
|
|
|
409
430
|
# preference, so there is nothing here to create.
|
|
410
431
|
resources :payout_providers, only: [:index]
|
|
411
432
|
|
|
412
|
-
# Return / claim / refund / cancellation reasons (dropdowns + settings management)
|
|
433
|
+
# Return / claim / refund / cancellation / tax exemption reasons (dropdowns + settings management)
|
|
413
434
|
resources :return_reasons
|
|
414
435
|
resources :claim_reasons
|
|
415
436
|
resources :refund_reasons
|
|
416
437
|
resources :order_cancellation_reasons
|
|
438
|
+
resources :tax_exemption_reasons
|
|
417
439
|
|
|
418
440
|
# Markets
|
|
419
441
|
resources :markets
|
|
@@ -1052,6 +1074,14 @@ Spree::Core::Engine.add_routes do
|
|
|
1052
1074
|
end
|
|
1053
1075
|
end
|
|
1054
1076
|
|
|
1077
|
+
# RFC 9728 Protected Resource Metadata. One document per protected
|
|
1078
|
+
# resource, named by the surface that registered it, so a client
|
|
1079
|
+
# discovers which authorization server guards the URL it was given.
|
|
1080
|
+
# Unauthenticated: a client has no credential at the point it asks.
|
|
1081
|
+
get 'oauth/protected-resource/:resource_key',
|
|
1082
|
+
to: 'oauth/metadata#show',
|
|
1083
|
+
as: :oauth_protected_resource
|
|
1084
|
+
|
|
1055
1085
|
# Webhooks (outside of store namespace — no API key authentication)
|
|
1056
1086
|
namespace :webhooks do
|
|
1057
1087
|
post 'payments/:payment_method_id', to: 'payments#create', as: :payment_webhook
|
|
@@ -1066,4 +1096,34 @@ Spree::Core::Engine.add_routes do
|
|
|
1066
1096
|
end
|
|
1067
1097
|
end
|
|
1068
1098
|
end
|
|
1099
|
+
|
|
1100
|
+
# The authorization server's own endpoints: /oauth/authorize, /oauth/token,
|
|
1101
|
+
# /oauth/revoke and RFC 8414 metadata. Outside the versioned API namespace —
|
|
1102
|
+
# the OAuth protocol is not a Spree API resource and its paths are fixed by
|
|
1103
|
+
# the spec and by what clients expect to find.
|
|
1104
|
+
#
|
|
1105
|
+
# `authorizations` is NOT skipped. Skipping it would leave the metadata
|
|
1106
|
+
# document without an `authorization_endpoint`, which is required for the
|
|
1107
|
+
# code flow, so a consumer client would have nowhere to send the browser.
|
|
1108
|
+
# The controller is Spree's own and redirects to the dashboard's consent
|
|
1109
|
+
# screen instead of rendering Doorkeeper's HTML.
|
|
1110
|
+
#
|
|
1111
|
+
# The application-management controllers stay skipped: connected apps are
|
|
1112
|
+
# managed through /api/v3/admin/oauth/applications, under the Admin API's
|
|
1113
|
+
# own authentication.
|
|
1114
|
+
# `controllers` is a block method, not an option to `use_doorkeeper`.
|
|
1115
|
+
#
|
|
1116
|
+
# Doorkeeper's own controllers need a leading slash because these routes are
|
|
1117
|
+
# drawn inside Spree::Core::Engine, which isolates the namespace — a bare
|
|
1118
|
+
# `doorkeeper/tokens` resolves as `Spree::Doorkeeper::Tokens` and fails on
|
|
1119
|
+
# the first request. Spree's own authorizations controller is named from the
|
|
1120
|
+
# application root WITHOUT a slash, because the metadata document builds its
|
|
1121
|
+
# URL with `url_for(controller: "/#{name}")` and adds the slash itself.
|
|
1122
|
+
use_doorkeeper scope: 'oauth' do
|
|
1123
|
+
controllers authorizations: 'api/v3/oauth/authorizations',
|
|
1124
|
+
tokens: '/doorkeeper/tokens',
|
|
1125
|
+
token_info: '/doorkeeper/token_info',
|
|
1126
|
+
metadata: '/spree/api/v3/oauth/server_metadata'
|
|
1127
|
+
skip_controllers :applications, :authorized_applications
|
|
1128
|
+
end
|
|
1069
1129
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
# Which parents an agent may curate products into, derived from the
|
|
4
|
+
# controllers that adopt `Spree::Api::V3::Admin::ProductMembership`.
|
|
5
|
+
#
|
|
6
|
+
# Read off the controllers for the same reason {AgentResourceMap} is: a
|
|
7
|
+
# parent that adopts the concern later is curated without anyone editing
|
|
8
|
+
# this file, and one that drops it stops being offered.
|
|
9
|
+
module AgentMembershipMap
|
|
10
|
+
class << self
|
|
11
|
+
# @param map [Module]
|
|
12
|
+
# @return [Integer] how many parents were registered
|
|
13
|
+
def install(map: Spree::AgentTools::MembershipMap)
|
|
14
|
+
map.reset!
|
|
15
|
+
|
|
16
|
+
curating_controllers.each do |controller|
|
|
17
|
+
target = target_for(controller)
|
|
18
|
+
next if target.blank?
|
|
19
|
+
|
|
20
|
+
# Addressing a parent an agent cannot otherwise read would hand it
|
|
21
|
+
# a write with no matching read, and no way to name what it changed.
|
|
22
|
+
entry = Spree::AgentTools::ResourceMap.find(target.pluralize)
|
|
23
|
+
next if entry.nil?
|
|
24
|
+
|
|
25
|
+
map.register(
|
|
26
|
+
target: target,
|
|
27
|
+
permission: write_permission_for(entry),
|
|
28
|
+
api_path: membership_path_for(controller)
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
map.all.size
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Where this parent's products are added and removed, read from the
|
|
38
|
+
# routes. The nested path carries the parent's id, which a tool fills
|
|
39
|
+
# in from the parent it was given.
|
|
40
|
+
def membership_path_for(controller)
|
|
41
|
+
route = Spree::Core::Engine.routes.routes.find do |candidate|
|
|
42
|
+
candidate.defaults[:controller] == controller.controller_path &&
|
|
43
|
+
candidate.defaults[:action] == 'create'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
route&.path&.spec.to_s.sub('(.:format)', '').presence
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def curating_controllers
|
|
50
|
+
Rails.application.eager_load! unless Rails.application.config.eager_load
|
|
51
|
+
|
|
52
|
+
Spree::Api::V3::Admin::ResourceController.descendants.
|
|
53
|
+
select { |controller| controller.include?(Spree::Api::V3::Admin::ProductMembership) }.
|
|
54
|
+
sort_by(&:name)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Curating is a write to the parent, so it is gated on the parent's
|
|
58
|
+
# own write scope — a price list rides `write_products`, a category
|
|
59
|
+
# `write_categories`, exactly as their controllers do.
|
|
60
|
+
def write_permission_for(entry)
|
|
61
|
+
entry.write_permission.presence || "write_#{entry.permission.to_s.sub(/\Aread_/, '')}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# `Admin::Categories::ProductsController` curates a category. The
|
|
65
|
+
# parent is the namespace the nested controller sits in, which is how
|
|
66
|
+
# the routes name it too.
|
|
67
|
+
def target_for(controller)
|
|
68
|
+
controller.name.to_s.split('::')[-2]&.underscore&.singularize
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
# Fills {Spree::AgentTools::ResourceMap} from the Admin API's own
|
|
4
|
+
# controllers.
|
|
5
|
+
#
|
|
6
|
+
# Nothing here is a list. Every admin resource controller already declares
|
|
7
|
+
# the model it serves, the serializer it renders, the permission scope it
|
|
8
|
+
# rides and the workflow it writes through — so the map an agent reads is
|
|
9
|
+
# the API surface by construction, and a resource cannot drift from the
|
|
10
|
+
# endpoint that serves it. A controller that gains an attribute gains it
|
|
11
|
+
# for agents in the same commit.
|
|
12
|
+
#
|
|
13
|
+
# The one fact a controller does not carry is where the record lives in the
|
|
14
|
+
# dashboard, which is only used to link to it, so those are kept here.
|
|
15
|
+
#
|
|
16
|
+
# See docs/plans/6.0-mcp-server.md.
|
|
17
|
+
module AgentResourceMap
|
|
18
|
+
# Dashboard routes, for results that link to the record rather than
|
|
19
|
+
# describing where to find it. Store-relative: the panel prefixes the
|
|
20
|
+
# current store segment.
|
|
21
|
+
DASHBOARD_PATHS = {
|
|
22
|
+
'products' => '/products/%<id>s',
|
|
23
|
+
'categories' => '/products/categories/%<id>s',
|
|
24
|
+
'collections' => '/products/collections/%<id>s',
|
|
25
|
+
'price_lists' => '/products/price-lists/%<id>s',
|
|
26
|
+
'orders' => '/orders/%<id>s',
|
|
27
|
+
'customers' => '/customers/%<id>s',
|
|
28
|
+
'companies' => '/companies/%<id>s',
|
|
29
|
+
'sellers' => '/sellers/%<id>s',
|
|
30
|
+
'promotions' => '/promotions/%<id>s',
|
|
31
|
+
'channels' => '/settings/channels',
|
|
32
|
+
'markets' => '/settings/markets',
|
|
33
|
+
'payment_methods' => '/settings/payment-methods',
|
|
34
|
+
'delivery_profiles' => '/settings/delivery-profiles',
|
|
35
|
+
'stock_locations' => '/settings/stock-locations',
|
|
36
|
+
'tax_categories' => '/settings/tax-categories',
|
|
37
|
+
'tax_rates' => '/settings/tax-rates',
|
|
38
|
+
'product_types' => '/settings/product-types',
|
|
39
|
+
'commission_rates' => '/settings/commission-rates',
|
|
40
|
+
'roles' => '/settings/roles',
|
|
41
|
+
'return_reasons' => '/settings/reasons',
|
|
42
|
+
'allowed_origins' => '/settings/allowed-origins',
|
|
43
|
+
'imports' => '/settings/imports'
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# Resources withheld whatever their controller says, because their
|
|
47
|
+
# serializer carries a live credential or a bearer instrument. Every
|
|
48
|
+
# tool result reaches a model and, for a hosted client, a third party.
|
|
49
|
+
#
|
|
50
|
+
# api_keys, integrations, webhook_endpoints — plaintext tokens,
|
|
51
|
+
# provider credentials and the secret that signs deliveries.
|
|
52
|
+
# gift_cards — the code IS the instrument: whoever reads it can spend
|
|
53
|
+
# the balance. A balance tool that never emits a code could be added.
|
|
54
|
+
# invitations — the acceptance link is a bearer token that mints a
|
|
55
|
+
# staff account at the invited role, and the endpoint
|
|
56
|
+
# it addresses is deliberately unauthenticated. The
|
|
57
|
+
# outbound filter drops the link, but a resource whose
|
|
58
|
+
# whole purpose is handing out authority does not
|
|
59
|
+
# belong on a read surface an agent drives.
|
|
60
|
+
WITHHELD_MODELS = %w[
|
|
61
|
+
Spree::ApiKey
|
|
62
|
+
Spree::OauthApplication
|
|
63
|
+
Spree::Integration
|
|
64
|
+
Spree::WebhookEndpoint
|
|
65
|
+
Spree::WebhookDelivery
|
|
66
|
+
Spree::GiftCard
|
|
67
|
+
Spree::Invitation
|
|
68
|
+
].freeze
|
|
69
|
+
|
|
70
|
+
# Controllers that resolve their scope per request rather than declaring
|
|
71
|
+
# one, because the scope belongs to the resource being imported or
|
|
72
|
+
# exported — creating a product import is a product write, and reading
|
|
73
|
+
# its rows exposes the uploaded data. A single static key cannot say
|
|
74
|
+
# that, so these are registered read-only under the broadest key the
|
|
75
|
+
# controller can demand, and the write path stays with the endpoint.
|
|
76
|
+
DYNAMIC_SCOPE_RESOURCES = {
|
|
77
|
+
'imports' => { model_name: 'Spree::Import', permission: 'read_settings',
|
|
78
|
+
serializer_name: 'Spree::Api::V3::Admin::ImportSerializer',
|
|
79
|
+
dashboard_path: '/settings/imports' },
|
|
80
|
+
'exports' => { model_name: 'Spree::Export', permission: 'read_settings',
|
|
81
|
+
serializer_name: 'Spree::Api::V3::Admin::ExportSerializer' }
|
|
82
|
+
}.freeze
|
|
83
|
+
|
|
84
|
+
class << self
|
|
85
|
+
# Registers this walk as how the core map fills itself, and marks the
|
|
86
|
+
# map stale so the next reader gets a fresh one. Called on every code
|
|
87
|
+
# reload; the walk itself runs when something actually reads the map.
|
|
88
|
+
#
|
|
89
|
+
# @return [void]
|
|
90
|
+
def stale!
|
|
91
|
+
Spree::AgentTools::ResourceMap.deriver = -> { install }
|
|
92
|
+
Spree::AgentTools::ResourceMap.stale!
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Walks every admin resource controller and registers what it serves.
|
|
96
|
+
#
|
|
97
|
+
# @param map [Class] defaults to the core resource map
|
|
98
|
+
# @return [Integer] how many resources were registered
|
|
99
|
+
def install(map: Spree::AgentTools::ResourceMap)
|
|
100
|
+
map.reset!
|
|
101
|
+
|
|
102
|
+
registered = resolve_collisions(controllers.filter_map { |controller| derive(controller) })
|
|
103
|
+
registered.each { |entry| map.register(**entry) }
|
|
104
|
+
|
|
105
|
+
DYNAMIC_SCOPE_RESOURCES.each { |key, attributes| map.register(key: key, **attributes) }
|
|
106
|
+
|
|
107
|
+
registered.size + DYNAMIC_SCOPE_RESOURCES.size
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Every admin resource controller in the application, the abstract
|
|
111
|
+
# bases excluded. Eager-loaded first: in development only the
|
|
112
|
+
# controllers already used would otherwise be known, and the map would
|
|
113
|
+
# differ between a fresh boot and a warm one.
|
|
114
|
+
#
|
|
115
|
+
# @return [Array<Class>]
|
|
116
|
+
def controllers
|
|
117
|
+
Rails.application.eager_load! unless Rails.application.config.eager_load
|
|
118
|
+
|
|
119
|
+
Spree::Api::V3::Admin::ResourceController.descendants.reject(&:abstract?).sort_by(&:name)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# What one controller contributes, or nil when it serves nothing an
|
|
123
|
+
# agent can address.
|
|
124
|
+
#
|
|
125
|
+
# @param controller [Class]
|
|
126
|
+
# @return [Hash, nil]
|
|
127
|
+
# Where this controller's operations live, read from the routes rather
|
|
128
|
+
# than guessed from the key: a nested or renamed resource keeps its
|
|
129
|
+
# real path, and a controller with no route is not reachable at all.
|
|
130
|
+
#
|
|
131
|
+
# The shallowest route wins. A resource reached both at the top level
|
|
132
|
+
# and under a parent (`/products` and
|
|
133
|
+
# `/catalogs/:catalog_id/products`) is addressed by the former: a tool
|
|
134
|
+
# given a product id has no parent id to fill in, and the nested route
|
|
135
|
+
# would leave `:catalog_id` in the path.
|
|
136
|
+
#
|
|
137
|
+
# @return [Hash{Symbol => String}] action => path template
|
|
138
|
+
# Across colliding controllers, the shallowest path per action.
|
|
139
|
+
def shallowest_paths(candidates)
|
|
140
|
+
candidates.flat_map { |entry| Array(entry[:api_paths]).to_a }.
|
|
141
|
+
group_by(&:first).
|
|
142
|
+
transform_values { |pairs| pairs.map(&:last).min_by { |path| [path.count(':'), path.length] } }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def api_paths_for(controller)
|
|
146
|
+
candidates = Spree::Core::Engine.routes.routes.filter_map do |route|
|
|
147
|
+
next unless route.defaults[:controller] == controller.controller_path
|
|
148
|
+
|
|
149
|
+
action = route.defaults[:action]&.to_sym
|
|
150
|
+
next if action.nil?
|
|
151
|
+
|
|
152
|
+
[action, route.path.spec.to_s.sub('(.:format)', '')]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
candidates.group_by(&:first).transform_values do |pairs|
|
|
156
|
+
pairs.map(&:last).min_by { |path| [path.count(':'), path.length] }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def derive(controller)
|
|
161
|
+
instance = controller.allocate
|
|
162
|
+
model = safely(instance, :model_class)
|
|
163
|
+
return if model.nil? || WITHHELD_MODELS.include?(model.name)
|
|
164
|
+
|
|
165
|
+
scope = controller._scoped_resource
|
|
166
|
+
return if scope.blank?
|
|
167
|
+
|
|
168
|
+
serializer = safely(instance, :serializer_class)
|
|
169
|
+
return if serializer.nil?
|
|
170
|
+
|
|
171
|
+
key = key_for(model)
|
|
172
|
+
return unless store_scoped?(model)
|
|
173
|
+
|
|
174
|
+
create_workflow = workflow_key(instance, :create_workflow)
|
|
175
|
+
update_workflow = workflow_key(instance, :update_workflow)
|
|
176
|
+
writable = AgentWriteSchemas.attribute_names(key)
|
|
177
|
+
|
|
178
|
+
{
|
|
179
|
+
key: key,
|
|
180
|
+
model_name: model.name,
|
|
181
|
+
permission: "read_#{scope}",
|
|
182
|
+
serializer_name: serializer.name,
|
|
183
|
+
dashboard_path: DASHBOARD_PATHS[key],
|
|
184
|
+
api_paths: api_paths_for(controller),
|
|
185
|
+
write_permission: write_permission_for(model, scope, create_workflow, update_workflow, writable, controller),
|
|
186
|
+
writable_attributes: writable,
|
|
187
|
+
create_workflow_key: create_workflow,
|
|
188
|
+
update_workflow_key: update_workflow,
|
|
189
|
+
distinct: collection_distinct?(instance)
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
# What the controller does with its own collection. A controller that
|
|
196
|
+
# disables DISTINCT has a reason — Postgres refuses it beside an
|
|
197
|
+
# ORDER BY the select list does not carry — and a search tool adding
|
|
198
|
+
# it anyway answers with a 500.
|
|
199
|
+
def collection_distinct?(instance)
|
|
200
|
+
return true unless instance.respond_to?(:collection_distinct?, true)
|
|
201
|
+
|
|
202
|
+
instance.send(:collection_distinct?)
|
|
203
|
+
rescue StandardError
|
|
204
|
+
true
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# A model can be served by more than one controller under different
|
|
208
|
+
# permission scopes — a tax identifier through customers and through
|
|
209
|
+
# orders, media through products and through the media library. One
|
|
210
|
+
# resource key can carry only one permission, and whichever controller
|
|
211
|
+
# happened to sort last is not an answer: it would grant a key holding
|
|
212
|
+
# `read_orders` the customers' records too.
|
|
213
|
+
#
|
|
214
|
+
# So a collision resolves to the narrowest scope offered, and the
|
|
215
|
+
# resource stays read-only, because the two controllers do not agree
|
|
216
|
+
# on who may write it. A caller holding the wider scope reaches the
|
|
217
|
+
# record through the endpoint, as before.
|
|
218
|
+
def resolve_collisions(entries)
|
|
219
|
+
entries.group_by { |entry| entry[:key] }.map do |_key, candidates|
|
|
220
|
+
next candidates.first if candidates.one?
|
|
221
|
+
|
|
222
|
+
narrowest = candidates.min_by { |entry| [scope_breadth(entry[:permission]), entry[:permission]] }
|
|
223
|
+
contested = candidates.map { |entry| entry[:permission] }.uniq.size > 1
|
|
224
|
+
|
|
225
|
+
# A workflow declaration is not in dispute — whichever controller
|
|
226
|
+
# declares one, that is how the resource is written, and the
|
|
227
|
+
# refusal has to keep naming that tool. Only the generic write is
|
|
228
|
+
# withdrawn, and only when the controllers disagree about the
|
|
229
|
+
# scope guarding it.
|
|
230
|
+
narrowest.merge(
|
|
231
|
+
create_workflow_key: candidates.filter_map { |entry| entry[:create_workflow_key] }.first,
|
|
232
|
+
update_workflow_key: candidates.filter_map { |entry| entry[:update_workflow_key] }.first,
|
|
233
|
+
write_permission: contested ? nil : narrowest[:write_permission],
|
|
234
|
+
writable_attributes: contested ? [] : narrowest[:writable_attributes],
|
|
235
|
+
# One controller refusing DISTINCT is enough: its sort would
|
|
236
|
+
# make Postgres reject the query, and whichever controller
|
|
237
|
+
# happened to sort first is not an answer.
|
|
238
|
+
distinct: candidates.all? { |entry| entry[:distinct] },
|
|
239
|
+
# The shallowest route across every controller claiming this
|
|
240
|
+
# key. `narrowest` is chosen by permission breadth, which says
|
|
241
|
+
# nothing about depth — so a resource served both at the top
|
|
242
|
+
# level and under a parent kept whichever won on scope, and a
|
|
243
|
+
# tool was handed a path with an unfillable `:parent_id` in it.
|
|
244
|
+
api_paths: shallowest_paths(candidates)
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# How much a scope covers, so the narrowest wins a collision. Measured
|
|
250
|
+
# by the number of resources the catalog grants it, which is the only
|
|
251
|
+
# ranking the catalog actually gives us.
|
|
252
|
+
def scope_breadth(permission)
|
|
253
|
+
_kind, scope = Spree.permissions.resolve_key(permission)
|
|
254
|
+
scope ? Array(scope.resources).size : Float::INFINITY
|
|
255
|
+
rescue StandardError
|
|
256
|
+
Float::INFINITY
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# A resource is generically writable only when the Admin API writes it
|
|
260
|
+
# by saving the record, and the OpenAPI document says what a write may
|
|
261
|
+
# set. A workflow-written resource keeps its workflow key instead (so
|
|
262
|
+
# the refusal can name the tool); a service-written one is not writable
|
|
263
|
+
# at all; and a resource with no documented body is read-only here,
|
|
264
|
+
# because a write tool with no attributes is one an agent can only get
|
|
265
|
+
# wrong.
|
|
266
|
+
# Writable when the controller documents a body and does not hand out
|
|
267
|
+
# authority.
|
|
268
|
+
#
|
|
269
|
+
# The list of service-written models this used to consult is gone
|
|
270
|
+
# (2026-10-10). It existed because a generic write assigned
|
|
271
|
+
# attributes and saved, so reaching a record whose controller
|
|
272
|
+
# orchestrates — an order, a refund, a stock level — would have
|
|
273
|
+
# skipped that orchestration. A dispatched write IS the controller,
|
|
274
|
+
# so there is nothing left to skip, and the list only withheld writes
|
|
275
|
+
# the API performs correctly.
|
|
276
|
+
#
|
|
277
|
+
# A declared workflow no longer withholds it either: the controller
|
|
278
|
+
# runs its own workflow, which is what the generic write now reaches.
|
|
279
|
+
def write_permission_for(model, scope, create_workflow, update_workflow, writable, controller)
|
|
280
|
+
return if writable.empty?
|
|
281
|
+
return if hands_out_authority?(controller)
|
|
282
|
+
|
|
283
|
+
"write_#{scope}"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# A controller that hands out authority — roles, role grants,
|
|
287
|
+
# invitations, API keys — runs an anti-amplification check the generic
|
|
288
|
+
# write knows nothing about: a caller may only grant permissions they
|
|
289
|
+
# themselves hold. Assigning those attributes and calling `save` would
|
|
290
|
+
# skip it, so writing them stays with the endpoint that guards them.
|
|
291
|
+
#
|
|
292
|
+
# Asked of the controller rather than listed, so a surface that adopts
|
|
293
|
+
# the guard later is covered without anyone remembering this file.
|
|
294
|
+
def hands_out_authority?(controller)
|
|
295
|
+
return false unless defined?(Spree::Api::V3::Admin::RoleGrantGuard)
|
|
296
|
+
|
|
297
|
+
controller.include?(Spree::Api::V3::Admin::RoleGrantGuard)
|
|
298
|
+
rescue StandardError
|
|
299
|
+
false
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# The plural the resource is addressed by, taken from the model so two
|
|
303
|
+
# controllers serving the same model (a nested one and a top-level one)
|
|
304
|
+
# agree on it.
|
|
305
|
+
def key_for(model)
|
|
306
|
+
model.model_name.element.pluralize
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# `Spree::Base.for_store` falls back to the bare class for a model with
|
|
310
|
+
# no Store association, which would hand this store's agent every
|
|
311
|
+
# store's rows. A model that defines its own `for_store` has made a
|
|
312
|
+
# deliberate choice (customers are global by design and say so);
|
|
313
|
+
# inheriting the fallback is an accident, and a missing resource is
|
|
314
|
+
# better than a leaking one.
|
|
315
|
+
def store_scoped?(model)
|
|
316
|
+
return false unless model.respond_to?(:for_store)
|
|
317
|
+
return true if model.method(:for_store).owner != Spree::Base.singleton_class
|
|
318
|
+
|
|
319
|
+
!model.for_store(probe_store).equal?(model)
|
|
320
|
+
rescue StandardError
|
|
321
|
+
false
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# One unsaved store, reused: the probe asks what `for_store` does with
|
|
325
|
+
# a store, not with any particular one, and the walk covers every
|
|
326
|
+
# admin controller.
|
|
327
|
+
def probe_store
|
|
328
|
+
@probe_store ||= Spree::Store.new
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# The workflow a controller writes through, as its dotted key, so a
|
|
332
|
+
# generic write can name the tool that does the job instead.
|
|
333
|
+
def workflow_key(instance, method_name)
|
|
334
|
+
workflow = safely(instance, method_name)
|
|
335
|
+
workflow.respond_to?(:workflow_key) ? workflow.workflow_key : nil
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# A controller may raise from any of these — `model_class` is
|
|
339
|
+
# NotImplementedError on an abstract one, `permitted_attributes` reads
|
|
340
|
+
# request state on a few — and one such controller must not take the
|
|
341
|
+
# whole map down at boot.
|
|
342
|
+
def safely(instance, method_name)
|
|
343
|
+
instance.send(method_name)
|
|
344
|
+
rescue StandardError, NotImplementedError
|
|
345
|
+
nil
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|