spree_core 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/mailers/spree/email_template_mailer.rb +5 -2
- data/app/models/concerns/spree/has_external_references.rb +7 -6
- data/app/models/concerns/spree/preference_schema/json_schema.rb +8 -1
- data/app/models/concerns/spree/purchase/taxation.rb +44 -3
- data/app/models/concerns/spree/translatable_resource.rb +49 -0
- data/app/models/spree/cart.rb +1 -0
- data/app/models/spree/delivery_rate.rb +12 -4
- data/app/models/spree/delivery_rate_provider/base.rb +1 -1
- data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
- data/app/models/spree/fulfillment.rb +1 -0
- data/app/models/spree/gateway/bogus.rb +7 -0
- data/app/models/spree/line_item.rb +3 -8
- data/app/models/spree/oauth_access_grant.rb +11 -0
- data/app/models/spree/oauth_access_token.rb +16 -0
- data/app/models/spree/oauth_application.rb +97 -0
- data/app/models/spree/oauth_consent_request.rb +23 -0
- data/app/models/spree/oauth_protected_resource.rb +21 -0
- data/app/models/spree/oauth_redirect.rb +12 -0
- data/app/models/spree/option_type.rb +5 -0
- data/app/models/spree/option_value.rb +5 -0
- data/app/models/spree/order.rb +5 -1
- data/app/models/spree/payment.rb +14 -2
- data/app/models/spree/payment_capture_event.rb +2 -0
- data/app/models/spree/policy.rb +6 -0
- data/app/models/spree/price.rb +16 -33
- data/app/models/spree/price_rule.rb +3 -16
- data/app/models/spree/price_rules/market_rule.rb +0 -6
- data/app/models/spree/pricing_provider/base.rb +1 -1
- data/app/models/spree/product.rb +1 -1
- data/app/models/spree/purchase_order_item.rb +1 -1
- data/app/models/spree/return.rb +1 -0
- data/app/models/spree/search_provider/database.rb +1 -1
- data/app/models/spree/stock/estimator.rb +4 -20
- data/app/models/spree/store.rb +8 -0
- data/app/models/spree/tax_exemption/item_override.rb +14 -0
- data/app/models/spree/tax_exemption.rb +26 -0
- data/app/models/spree/tax_exemption_certificate.rb +13 -2
- data/app/models/spree/tax_exemption_reason.rb +62 -0
- data/app/models/spree/tax_provider/base.rb +24 -4
- data/app/models/spree/tax_provider/internal.rb +36 -29
- data/app/models/spree/tax_rate.rb +9 -35
- data/app/models/spree/translations/batch.rb +14 -4
- data/app/services/spree/carts/price_items.rb +2 -8
- data/app/services/spree/carts/split_by_seller.rb +6 -1
- data/app/services/spree/carts/update.rb +34 -7
- data/app/services/spree/email_templates/preview.rb +24 -3
- data/app/services/spree/seeds/store_resources.rb +1 -0
- data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
- data/app/services/spree/tax/resolve_exemptions.rb +11 -2
- data/app/services/spree/update_check.rb +8 -1
- data/app/tools/spree/agent_tools/create_export.rb +116 -0
- data/app/tools/spree/agent_tools/create_import.rb +117 -0
- data/app/tools/spree/agent_tools/create_media.rb +81 -0
- data/app/tools/spree/agent_tools/create_resource.rb +36 -0
- data/app/tools/spree/agent_tools/curate_products.rb +90 -0
- data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
- data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
- data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
- data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
- data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
- data/app/tools/spree/agent_tools/get_resource.rb +97 -0
- data/app/tools/spree/agent_tools/import_tool.rb +80 -0
- data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
- data/app/tools/spree/agent_tools/query_report.rb +124 -0
- data/app/tools/spree/agent_tools/resource_write.rb +99 -0
- data/app/tools/spree/agent_tools/search_resources.rb +155 -0
- data/app/tools/spree/agent_tools/set_stock.rb +52 -0
- data/app/tools/spree/agent_tools/update_resource.rb +42 -0
- data/app/tools/spree/agent_tools/upload_file.rb +104 -0
- data/app/workflows/spree/carts/complete.rb +115 -2
- data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
- data/app/workflows/spree/orders/cancel.rb +8 -0
- data/app/workflows/spree/orders/complete.rb +8 -0
- data/app/{services → workflows}/spree/orders/create.rb +9 -9
- data/app/workflows/spree/purchase_orders/create.rb +3 -0
- data/app/workflows/spree/returns/refund.rb +4 -0
- data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
- data/app/workflows/spree/variants/create.rb +7 -4
- data/config/locales/en.yml +14 -0
- data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
- data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
- data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
- data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
- data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
- data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
- data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
- data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
- data/db/sample_data/orders.rb +1 -1
- data/lib/spree/agent_tool.rb +188 -0
- data/lib/spree/agent_tools/api_dispatch.rb +204 -0
- data/lib/spree/agent_tools/context.rb +191 -0
- data/lib/spree/agent_tools/default_catalog.rb +180 -0
- data/lib/spree/agent_tools/membership_map.rb +61 -0
- data/lib/spree/agent_tools/record_summary.rb +219 -0
- data/lib/spree/agent_tools/registry.rb +131 -0
- data/lib/spree/agent_tools/resource_map.rb +352 -0
- data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
- data/lib/spree/agent_tools/workflow_schema.rb +279 -0
- data/lib/spree/agent_tools/workflow_tool.rb +654 -0
- data/lib/spree/agent_tools.rb +24 -0
- data/lib/spree/core/configuration.rb +1 -1
- data/lib/spree/core/controller_helpers/store.rb +3 -13
- data/lib/spree/core/dependencies.rb +9 -0
- data/lib/spree/core/engine.rb +35 -1
- data/lib/spree/core/model_extensions.rb +111 -0
- data/lib/spree/core/permissions/default_catalog.rb +6 -1
- data/lib/spree/core/pricing/price_resolution.rb +4 -4
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +27 -2
- data/lib/spree/service_module.rb +11 -0
- data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
- data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
- data/lib/tasks/markets.rake +47 -0
- data/lib/tasks/tax_exemption_reasons.rake +48 -0
- metadata +67 -7
- data/app/models/concerns/spree/vat_price_calculation.rb +0 -99
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module AgentTools
|
|
3
|
+
# Who is asking, and where. Every tool executes inside one of these, so a
|
|
4
|
+
# tool can never reach data the caller could not reach through the Admin
|
|
5
|
+
# API: lookups scope through {#store}, and {#permitted?} decides which
|
|
6
|
+
# tools the model is even offered.
|
|
7
|
+
#
|
|
8
|
+
# Two kinds of caller, because the registry serves two adapters. The
|
|
9
|
+
# dashboard assistant asks on behalf of a signed-in admin, so its context
|
|
10
|
+
# carries a user and that user's ability. The MCP server asks on behalf of
|
|
11
|
+
# a secret API key, so its context carries the key and the key's scopes.
|
|
12
|
+
# Whichever it is, {#principal} is the thing that acted — which is what a
|
|
13
|
+
# workflow records as the canceller or approver.
|
|
14
|
+
#
|
|
15
|
+
# Context.new(store: store, user: admin)
|
|
16
|
+
# Context.new(store: store, api_key: key)
|
|
17
|
+
class Context
|
|
18
|
+
# @return [Spree::Store]
|
|
19
|
+
attr_reader :store
|
|
20
|
+
|
|
21
|
+
# @return [Object, nil] the signed-in admin ({Spree.admin_user_class}),
|
|
22
|
+
# or nil when a key is asking
|
|
23
|
+
attr_reader :user
|
|
24
|
+
|
|
25
|
+
# @return [Spree::ApiKey, nil] the authenticating secret key, or nil when
|
|
26
|
+
# an admin is asking
|
|
27
|
+
attr_reader :api_key
|
|
28
|
+
|
|
29
|
+
# @return [Spree::Ability, nil] the user's ability; nil for a key, whose
|
|
30
|
+
# authority is its scopes
|
|
31
|
+
attr_reader :ability
|
|
32
|
+
|
|
33
|
+
# @param store [Spree::Store]
|
|
34
|
+
# @param user [Object, nil] a {Spree.admin_user_class} record
|
|
35
|
+
# @param api_key [Spree::ApiKey, nil] a secret key
|
|
36
|
+
# @param ability [Spree::Ability, nil] defaults to the user's ability for
|
|
37
|
+
# this store — abilities are store-scoped, so the store must be passed
|
|
38
|
+
# @param granted_scopes [Array<String>, nil] when the caller acts for a
|
|
39
|
+
# user through a delegated grant (an OAuth token), the scopes that
|
|
40
|
+
# grant carries. They *narrow* the user's own authority and can never
|
|
41
|
+
# widen it, so a merchant who consented to reading products does not
|
|
42
|
+
# hand over everything their role could do.
|
|
43
|
+
# @return [Hash] the Rack headers the caller authenticated with, which a
|
|
44
|
+
# dispatched Admin API call repeats verbatim. Empty for a caller that
|
|
45
|
+
# arrived without a request — a job, a console, a spec — which can
|
|
46
|
+
# only reach tools that do not dispatch.
|
|
47
|
+
attr_reader :request_headers
|
|
48
|
+
|
|
49
|
+
def initialize(store:, user: nil, api_key: nil, ability: nil, granted_scopes: nil,
|
|
50
|
+
request_headers: {})
|
|
51
|
+
raise ArgumentError, 'Spree::AgentTools::Context needs a user or an api_key' if user.nil? && api_key.nil?
|
|
52
|
+
|
|
53
|
+
@store = store
|
|
54
|
+
@user = user
|
|
55
|
+
@api_key = api_key
|
|
56
|
+
@granted_scopes = granted_scopes
|
|
57
|
+
@request_headers = request_headers.to_h
|
|
58
|
+
@ability = ability || (Spree::Dependencies.ability_class.constantize.new(user, store: store) if user)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Who acted, for workflows that record it (`canceler:`, `approver:`,
|
|
62
|
+
# `created_by:`). An admin user or an API key — both are registered in
|
|
63
|
+
# `Spree.actor_classes`, so either can be stored polymorphically.
|
|
64
|
+
#
|
|
65
|
+
# @return [Object]
|
|
66
|
+
def principal
|
|
67
|
+
user || api_key
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Boolean] whether a signed-in admin is asking
|
|
71
|
+
def user_principal?
|
|
72
|
+
user.present?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Narrows a relation to the records this caller may act on, the same way
|
|
76
|
+
# an Admin API controller does.
|
|
77
|
+
#
|
|
78
|
+
# For an admin, permission keys answer "may they touch products at all";
|
|
79
|
+
# they do not answer "which products". A host app that registers a
|
|
80
|
+
# record-level rule (`can :read, Spree::Order, seller_id: ...`) is
|
|
81
|
+
# honoured by the Admin API, and must be honoured here too.
|
|
82
|
+
#
|
|
83
|
+
# For a key there is no record-level filter to apply: a secret key's
|
|
84
|
+
# authority is its scopes, and the store is the whole tenancy answer —
|
|
85
|
+
# exactly how the Admin API treats key requests today.
|
|
86
|
+
#
|
|
87
|
+
# @param relation [ActiveRecord::Relation]
|
|
88
|
+
# @param action [Symbol]
|
|
89
|
+
# @return [ActiveRecord::Relation]
|
|
90
|
+
def accessible(relation, action = :show)
|
|
91
|
+
return relation unless user_principal?
|
|
92
|
+
return relation unless relation.respond_to?(:accessible_by)
|
|
93
|
+
|
|
94
|
+
relation.accessible_by(ability, action)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Whether this caller may take an action on a specific record.
|
|
98
|
+
#
|
|
99
|
+
# Checked at execution rather than at proposal time: an approval card may
|
|
100
|
+
# be decided minutes later, and roles can change in between. A key holds
|
|
101
|
+
# no record-level rules, so its scope check is the whole answer.
|
|
102
|
+
#
|
|
103
|
+
# @param record [Object]
|
|
104
|
+
# @param action [Symbol]
|
|
105
|
+
# @return [Boolean]
|
|
106
|
+
def can?(action, record)
|
|
107
|
+
return true unless user_principal?
|
|
108
|
+
# A delegated grant narrows what the user may do here, so a record
|
|
109
|
+
# check has to honour it too: being offered a tool is not permission
|
|
110
|
+
# to use it on a record the grant does not cover.
|
|
111
|
+
return false unless granted_for?(action, record)
|
|
112
|
+
|
|
113
|
+
ability.can?(action, record)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Whether the grant covers an action on this record's resource. A
|
|
117
|
+
# context without a grant is the user acting directly and is unlimited
|
|
118
|
+
# by this; a grant that names no scope for the resource refuses.
|
|
119
|
+
#
|
|
120
|
+
# @return [Boolean]
|
|
121
|
+
def granted_for?(action, record)
|
|
122
|
+
return true if @granted_scopes.nil?
|
|
123
|
+
|
|
124
|
+
scope = Spree.permissions.scope_for_resource(record.is_a?(Class) ? record : record.class)
|
|
125
|
+
return true if scope.nil?
|
|
126
|
+
|
|
127
|
+
# A write key always expands to its read key, so checking the one the
|
|
128
|
+
# action needs is the whole answer.
|
|
129
|
+
kind = %i[read show index].include?(action.to_sym) ? 'read' : 'write'
|
|
130
|
+
permission_keys.include?("#{kind}_#{scope.name}")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Whether the caller holds a permission a tool *declares*.
|
|
134
|
+
#
|
|
135
|
+
# @param permission_key [String, nil]
|
|
136
|
+
# @return [Boolean]
|
|
137
|
+
def permitted?(permission_key)
|
|
138
|
+
return true if permission_key.blank?
|
|
139
|
+
|
|
140
|
+
key = permission_key.to_s
|
|
141
|
+
# An unregistered key can never be held, so a typo would silently hide a
|
|
142
|
+
# tool forever. Fail loudly in development instead.
|
|
143
|
+
if !Spree.permissions.key?(key) && (defined?(Rails) && Rails.env.local?)
|
|
144
|
+
raise ArgumentError, "Agent tool declares unknown permission key #{key.inspect}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
permission_keys.include?(key)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Whether the caller holds a permission computed at call time — an
|
|
151
|
+
# export's or import's own scope, which comes from whichever subclass
|
|
152
|
+
# the caller named.
|
|
153
|
+
#
|
|
154
|
+
# Unlike {#permitted?} this refuses an unregistered key rather than
|
|
155
|
+
# raising on it: a tool's declaration is ours to get right, but a host
|
|
156
|
+
# app's export subclass may derive a key outside the catalog, and an
|
|
157
|
+
# agent asking for it should be told no, not crash the request.
|
|
158
|
+
#
|
|
159
|
+
# @param permission_key [String, nil]
|
|
160
|
+
# @return [Boolean]
|
|
161
|
+
def holds?(permission_key)
|
|
162
|
+
return true if permission_key.blank?
|
|
163
|
+
|
|
164
|
+
key = permission_key.to_s
|
|
165
|
+
return false unless Spree.permissions.key?(key)
|
|
166
|
+
|
|
167
|
+
permission_keys.include?(key)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The catalog keys this caller holds, in the one currency both gates
|
|
171
|
+
# speak: an admin's activated role keys, or a secret key's scopes
|
|
172
|
+
# expanded the way the Admin API expands them (`write_x` implies
|
|
173
|
+
# `read_x`, `read_all` and `write_all` fan out over the catalog).
|
|
174
|
+
#
|
|
175
|
+
# The `respond_to?` guard on the ability is house style:
|
|
176
|
+
# `Spree::Dependencies.ability_class` is swappable and a custom class
|
|
177
|
+
# need not implement it.
|
|
178
|
+
#
|
|
179
|
+
# @return [Array<String>]
|
|
180
|
+
def permission_keys
|
|
181
|
+
@permission_keys ||=
|
|
182
|
+
if user_principal?
|
|
183
|
+
held = ability.respond_to?(:permission_keys) ? Array(ability.permission_keys).map(&:to_s) : []
|
|
184
|
+
@granted_scopes.nil? ? held : held & Spree.permissions.expand_keys(@granted_scopes)
|
|
185
|
+
else
|
|
186
|
+
Spree.permissions.expand_keys(api_key.scopes)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module AgentTools
|
|
3
|
+
# The tools Spree ships: the generic read tools, and the workflows exposed
|
|
4
|
+
# as write tools.
|
|
5
|
+
#
|
|
6
|
+
# The write allowlist is the whole catalog of back-office writes an agent
|
|
7
|
+
# may make. Every entry names a `Spree::Dependencies` key, so a host app
|
|
8
|
+
# that swaps in its own workflow class keeps the tool and gets the
|
|
9
|
+
# replacement's schema; and a permission, which must equal the write scope
|
|
10
|
+
# of the admin controller that invokes the same workflow — a contract spec
|
|
11
|
+
# in `spree_api` fails when the two disagree, or when a controller gains a
|
|
12
|
+
# workflow that is neither listed here nor excluded below.
|
|
13
|
+
module DefaultCatalog
|
|
14
|
+
# Read tools, and the writes that are not workflows.
|
|
15
|
+
TOOLS = %w[
|
|
16
|
+
Spree::AgentTools::SearchResources
|
|
17
|
+
Spree::AgentTools::GetResource
|
|
18
|
+
Spree::AgentTools::DescribeResource
|
|
19
|
+
Spree::AgentTools::CreateResource
|
|
20
|
+
Spree::AgentTools::UpdateResource
|
|
21
|
+
Spree::AgentTools::DeleteResource
|
|
22
|
+
Spree::AgentTools::CurateProducts
|
|
23
|
+
Spree::AgentTools::UploadFile
|
|
24
|
+
Spree::AgentTools::CreateMedia
|
|
25
|
+
Spree::AgentTools::CreateImport
|
|
26
|
+
Spree::AgentTools::SetStock
|
|
27
|
+
Spree::AgentTools::QueryReport
|
|
28
|
+
Spree::AgentTools::DescribeReporting
|
|
29
|
+
Spree::AgentTools::CreateExport
|
|
30
|
+
Spree::AgentTools::GetImportStatus
|
|
31
|
+
Spree::AgentTools::DescribeImportMapping
|
|
32
|
+
Spree::AgentTools::ProposeImportMapping
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Workflow key => permission, or => {permission:, except:, summary:}.
|
|
37
|
+
WORKFLOWS = {
|
|
38
|
+
# Catalog
|
|
39
|
+
#
|
|
40
|
+
# The three status moves below run in this process rather than through
|
|
41
|
+
# an endpoint, because the Admin API has none for them: `PATCH` writes
|
|
42
|
+
# the column and the bulk action uses `update_all`, so neither
|
|
43
|
+
# publishes the status event nor withdraws an open seller submission.
|
|
44
|
+
# They dispatch as soon as an admin status endpoint exists.
|
|
45
|
+
product_activate_workflow: 'write_products',
|
|
46
|
+
product_draft_workflow: 'write_products',
|
|
47
|
+
product_archive_workflow: 'write_products',
|
|
48
|
+
# `note` and `auto` belong to the workflow, not to the endpoint: the
|
|
49
|
+
# review action passes neither, so offering them would describe a
|
|
50
|
+
# capability no caller has.
|
|
51
|
+
product_approve_workflow: { permission: 'write_products', except: %i[note auto] },
|
|
52
|
+
product_reject_workflow: 'write_products',
|
|
53
|
+
variant_create_workflow: 'write_products',
|
|
54
|
+
variant_update_workflow: 'write_products',
|
|
55
|
+
catalog_activate_workflow: 'write_products',
|
|
56
|
+
catalog_deactivate_workflow: 'write_products',
|
|
57
|
+
price_list_activate_workflow: 'write_products',
|
|
58
|
+
price_list_deactivate_workflow: 'write_products',
|
|
59
|
+
|
|
60
|
+
# Orders. `restock_items` is hidden: it is deprecated and ignored, so
|
|
61
|
+
# offering it would invite a model to set something that does nothing.
|
|
62
|
+
order_cancel_workflow: { permission: 'write_orders', except: %i[restock_items] },
|
|
63
|
+
order_complete_workflow: 'write_orders',
|
|
64
|
+
|
|
65
|
+
# Fulfillment
|
|
66
|
+
fulfillment_create_workflow: 'write_fulfillments',
|
|
67
|
+
fulfillment_mark_delivered_workflow: 'write_fulfillments',
|
|
68
|
+
delivery_update_tracking_workflow: 'write_orders',
|
|
69
|
+
shipping_label_purchase_workflow: 'write_orders',
|
|
70
|
+
shipping_label_refund_workflow: 'write_orders',
|
|
71
|
+
|
|
72
|
+
# Money
|
|
73
|
+
payment_capture_workflow: 'write_payments',
|
|
74
|
+
payment_void_workflow: 'write_payments',
|
|
75
|
+
refund_create_workflow: 'write_refunds',
|
|
76
|
+
|
|
77
|
+
# Inventory
|
|
78
|
+
stock_transfer_mark_ready_workflow: 'write_stock',
|
|
79
|
+
stock_transfer_mark_in_transit_workflow: 'write_stock',
|
|
80
|
+
stock_transfer_receive_workflow: 'write_stock',
|
|
81
|
+
stock_transfer_cancel_workflow: 'write_stock',
|
|
82
|
+
stock_transfer_mark_draft_workflow: 'write_stock',
|
|
83
|
+
stock_transfer_close_workflow: 'write_stock',
|
|
84
|
+
|
|
85
|
+
# Purchasing
|
|
86
|
+
purchase_order_mark_ordered_workflow: 'write_purchasing',
|
|
87
|
+
purchase_order_receive_workflow: 'write_purchasing',
|
|
88
|
+
purchase_order_cancel_workflow: 'write_purchasing',
|
|
89
|
+
purchase_order_mark_draft_workflow: 'write_purchasing',
|
|
90
|
+
purchase_order_close_workflow: 'write_purchasing',
|
|
91
|
+
|
|
92
|
+
# Marketplace
|
|
93
|
+
seller_invite_workflow: 'write_sellers',
|
|
94
|
+
seller_approve_workflow: 'write_sellers',
|
|
95
|
+
seller_reject_workflow: 'write_sellers',
|
|
96
|
+
seller_suspend_workflow: 'write_sellers',
|
|
97
|
+
seller_reopen_onboarding_workflow: 'write_sellers',
|
|
98
|
+
seller_requirement_submission_accept_workflow: 'write_sellers',
|
|
99
|
+
seller_requirement_submission_reject_workflow: 'write_sellers',
|
|
100
|
+
seller_requirement_submission_waive_workflow: 'write_sellers',
|
|
101
|
+
|
|
102
|
+
# B2B
|
|
103
|
+
tax_exemption_certificate_verify_workflow: 'write_customers'
|
|
104
|
+
}.freeze
|
|
105
|
+
|
|
106
|
+
# Workflows an admin controller invokes that are deliberately NOT tools,
|
|
107
|
+
# each with the reason. The contract spec reads this list, so a workflow
|
|
108
|
+
# can never be quietly dropped: it is exposed, or it is here.
|
|
109
|
+
# The email template editor is a draft-and-publish surface: its steps
|
|
110
|
+
# move a draft through revisions against a live preview, and they mean
|
|
111
|
+
# nothing outside that screen. Publishing one sends real mail in the
|
|
112
|
+
# merchant's name, which is not a conversational action.
|
|
113
|
+
EMAIL_TEMPLATE_EDITOR_REASON =
|
|
114
|
+
'A step of the dashboard\'s email template editor, which drafts and previews against a ' \
|
|
115
|
+
'live rendering. Publishing sends real mail in the merchant\'s name.'.freeze
|
|
116
|
+
|
|
117
|
+
# Plain create, update and destroy reach these through the generic write
|
|
118
|
+
# tools, which dispatch to the controller — and the controller declares
|
|
119
|
+
# this very workflow, so both paths run the same code. Two tools for one
|
|
120
|
+
# job only cost a model a choice it has no basis to make.
|
|
121
|
+
GENERIC_WRITE_REASON =
|
|
122
|
+
'Ordinary CRUD: the generic create, update and destroy tools dispatch to the controller, ' \
|
|
123
|
+
'which declares this workflow, so exposing it again would be a second path to the same code.'
|
|
124
|
+
|
|
125
|
+
EXCLUDED_WORKFLOWS = {
|
|
126
|
+
product_create_workflow: GENERIC_WRITE_REASON,
|
|
127
|
+
product_update_workflow: GENERIC_WRITE_REASON,
|
|
128
|
+
product_destroy_workflow: GENERIC_WRITE_REASON,
|
|
129
|
+
catalog_create_workflow: GENERIC_WRITE_REASON,
|
|
130
|
+
catalog_update_workflow: GENERIC_WRITE_REASON,
|
|
131
|
+
price_list_create_workflow: GENERIC_WRITE_REASON,
|
|
132
|
+
price_list_update_workflow: GENERIC_WRITE_REASON,
|
|
133
|
+
stock_transfer_create_workflow: GENERIC_WRITE_REASON,
|
|
134
|
+
stock_transfer_update_workflow: GENERIC_WRITE_REASON,
|
|
135
|
+
purchase_order_create_workflow: GENERIC_WRITE_REASON,
|
|
136
|
+
purchase_order_update_workflow: GENERIC_WRITE_REASON,
|
|
137
|
+
seller_create_workflow: GENERIC_WRITE_REASON,
|
|
138
|
+
email_template_save_draft_workflow: EMAIL_TEMPLATE_EDITOR_REASON,
|
|
139
|
+
email_template_publish_workflow: EMAIL_TEMPLATE_EDITOR_REASON,
|
|
140
|
+
email_template_discard_draft_workflow: EMAIL_TEMPLATE_EDITOR_REASON,
|
|
141
|
+
email_template_revert_workflow: EMAIL_TEMPLATE_EDITOR_REASON,
|
|
142
|
+
email_template_restore_revision_workflow: EMAIL_TEMPLATE_EDITOR_REASON,
|
|
143
|
+
customer_anonymize_workflow:
|
|
144
|
+
'Irreversible erasure of a person\'s data under GDPR Art. 17. A deletion no one can undo ' \
|
|
145
|
+
'is not something to offer a model behind one confirmation prompt.',
|
|
146
|
+
invitation_accept_workflow:
|
|
147
|
+
'Platform plumbing, not a merchant operation: it runs for the invited person from a ' \
|
|
148
|
+
'tokenised link, and the controller skips the scope check entirely.',
|
|
149
|
+
seller_payout_sweep_workflow:
|
|
150
|
+
'Moves money to every eligible seller at once. A scheduled platform job, not a ' \
|
|
151
|
+
'conversational action.',
|
|
152
|
+
seller_payout_complete_workflow:
|
|
153
|
+
'Settles a payout against the payment provider. Belongs to the payouts pipeline, which ' \
|
|
154
|
+
'reconciles against provider state the agent cannot see.',
|
|
155
|
+
stock_level_bulk_upsert_workflow:
|
|
156
|
+
'Reached through its endpoint by set_stock, not run in process. The workflow takes ' \
|
|
157
|
+
'ids it trusts; the endpoint resolves them against the store first, and that is the ' \
|
|
158
|
+
'check an agent must not be able to skip.',
|
|
159
|
+
import_start_mapping_workflow:
|
|
160
|
+
'Reads the uploaded file and proposes its column mapping. Runs as part of creating ' \
|
|
161
|
+
'an import rather than on its own — a mapping proposal against a file nobody has ' \
|
|
162
|
+
'attached yet means nothing.',
|
|
163
|
+
import_complete_mapping_workflow:
|
|
164
|
+
'Step of the CSV import wizard; the mapping proposal that feeds it stays in the ' \
|
|
165
|
+
'dashboard assistant, which has the model.',
|
|
166
|
+
import_retry_failed_rows_workflow:
|
|
167
|
+
'Step of the CSV import wizard, meaningful only against a run the agent did not start.'
|
|
168
|
+
}.freeze
|
|
169
|
+
|
|
170
|
+
class << self
|
|
171
|
+
# @param registry [Spree::AgentTools::Registry]
|
|
172
|
+
# @return [void]
|
|
173
|
+
def install(registry)
|
|
174
|
+
TOOLS.each { |tool| registry << tool }
|
|
175
|
+
registry.expose_workflows(**WORKFLOWS)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module AgentTools
|
|
3
|
+
# The parents a product can belong to: categories, collections, catalogs,
|
|
4
|
+
# price lists — whatever the Admin API exposes its nested products
|
|
5
|
+
# surface for.
|
|
6
|
+
#
|
|
7
|
+
# A contract like {ResourceMap}: core declares the shape, and `spree_api`
|
|
8
|
+
# fills it from the controllers that adopt the membership concern, so a
|
|
9
|
+
# parent that adopts it later is curated without a new tool.
|
|
10
|
+
module MembershipMap
|
|
11
|
+
# Curation runs through the parent's own nested products endpoint, so
|
|
12
|
+
# an entry only needs to say which parent, what it costs and where.
|
|
13
|
+
Entry = Struct.new(:target, :permission, :api_path, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# @return [Array<Entry>]
|
|
17
|
+
def all
|
|
18
|
+
derive!
|
|
19
|
+
entries.values
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param target [String, Symbol] 'category', 'price_list', …
|
|
23
|
+
# @return [Entry, nil]
|
|
24
|
+
def find(target)
|
|
25
|
+
derive!
|
|
26
|
+
entries[target.to_s]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Array<String>]
|
|
30
|
+
def targets
|
|
31
|
+
all.map(&:target).sort
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def register(target:, permission:, api_path: nil)
|
|
35
|
+
entries[target.to_s] = Entry.new(target: target.to_s, permission: permission,
|
|
36
|
+
api_path: api_path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reset!
|
|
40
|
+
@entries = {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Filled on first read rather than at boot: deriving it loads every
|
|
44
|
+
# admin controller, and a developer who never runs an agent should
|
|
45
|
+
# not pay that on each code reload.
|
|
46
|
+
def derive!
|
|
47
|
+
return if @derived
|
|
48
|
+
|
|
49
|
+
@derived = true
|
|
50
|
+
Spree::Api::AgentMembershipMap.install if defined?(Spree::Api::AgentMembershipMap)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def entries
|
|
56
|
+
@entries ||= {}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module AgentTools
|
|
3
|
+
# Turns a record into the compact shape both the model and the panel read.
|
|
4
|
+
#
|
|
5
|
+
# One shape serves both audiences deliberately: the model needs enough to
|
|
6
|
+
# answer the question, and the panel needs enough to render a row the
|
|
7
|
+
# merchant can click through to. Anything richer belongs in `get_resource`.
|
|
8
|
+
class RecordSummary
|
|
9
|
+
# Fields worth showing beneath the title, in the order they are preferred.
|
|
10
|
+
# Status is deliberately absent — it has its own badge on the row, and
|
|
11
|
+
# repeating it would push out the field that actually adds something.
|
|
12
|
+
SUBTITLE_FIELDS = %w[display_total display_price price email number sku].freeze
|
|
13
|
+
|
|
14
|
+
# Anything that looks like a credential is dropped before a record can
|
|
15
|
+
# reach the model. The serializers are supposed to mask these already —
|
|
16
|
+
# this is the second lock, because a serializer that gains a field later
|
|
17
|
+
# would otherwise send live secrets to an AI vendor with no code change
|
|
18
|
+
# here at all.
|
|
19
|
+
SECRET_PATTERN = /(token|secret|password|api_key|access_key|private)/i.freeze
|
|
20
|
+
|
|
21
|
+
# Whole associations that are never safe to forward, whatever their
|
|
22
|
+
# serializer happens to expose today. A gift card's code IS the bearer
|
|
23
|
+
# instrument — anyone holding it can spend the balance — and an order
|
|
24
|
+
# serializes its gift card unconditionally, so an order lookup would
|
|
25
|
+
# otherwise hand live codes to the AI vendor and write them into chat
|
|
26
|
+
# history. A plain `code` match is too broad: channels, promotions and
|
|
27
|
+
# commission rates all use `code` as an identifier merchants ask about.
|
|
28
|
+
SECRET_ASSOCIATIONS = %w[gift_card gift_cards].freeze
|
|
29
|
+
|
|
30
|
+
# A credential does not always sit under a credential-shaped key. Several
|
|
31
|
+
# serializers expose a URL whose path or query string IS the secret — an
|
|
32
|
+
# invitation's `acceptance_url` carries the token that mints a staff
|
|
33
|
+
# account, a digital link's `download_url` is the customer's purchased
|
|
34
|
+
# file, and a data request's, media's, shipping label's and export's
|
|
35
|
+
# download links all address endpoints that authenticate on the link
|
|
36
|
+
# alone. Matching the key name cannot catch those, so the value is
|
|
37
|
+
# checked too.
|
|
38
|
+
#
|
|
39
|
+
# `Admin::DataRequestSerializer` already deletes its own inherited link
|
|
40
|
+
# for exactly this reason; this generalises that decision to every
|
|
41
|
+
# outbound record, so a serializer that gains such a field later is
|
|
42
|
+
# covered without a change here.
|
|
43
|
+
SECRET_VALUE_PATTERN = /
|
|
44
|
+
[?&](?:token|secret|signature|sig|key)= # a credential in the query string
|
|
45
|
+
|
|
|
46
|
+
\/(?:accept-invitation|digital_links)\/ # or a path whose segment is the token
|
|
47
|
+
/xi.freeze
|
|
48
|
+
|
|
49
|
+
# Keys whose value is a link that authenticates on itself. Named rather
|
|
50
|
+
# than pattern-matched, because a bare `_url` match would also drop the
|
|
51
|
+
# ordinary public URLs merchants ask about (a product's storefront link,
|
|
52
|
+
# a store's domain).
|
|
53
|
+
SECRET_ATTRIBUTES = %w[
|
|
54
|
+
acceptance_url download_url
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
57
|
+
# Attributes a named resource may keep, because on that resource the
|
|
58
|
+
# value is an ordinary authenticated path rather than a link that
|
|
59
|
+
# authenticates on itself.
|
|
60
|
+
#
|
|
61
|
+
# An export's download endpoint runs the Admin API's own scope check
|
|
62
|
+
# and `authorize_resource!`, so the link grants nothing on its own and
|
|
63
|
+
# withholding it only sends the merchant to the dashboard for a file
|
|
64
|
+
# the agent just made for them. Keyed per attribute rather than per
|
|
65
|
+
# resource: exempting a whole record would also release any other
|
|
66
|
+
# credential-shaped field it later gains.
|
|
67
|
+
LINK_SAFE_ATTRIBUTES = { 'exports' => %w[download_url].freeze }.freeze
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
# Strips credential-shaped keys from a serialized record.
|
|
71
|
+
#
|
|
72
|
+
# Public because it is the ONE filter every outbound record must pass:
|
|
73
|
+
# `get_resource` emits a full serializer hash rather than a summary row,
|
|
74
|
+
# and without this it would bypass the protection entirely.
|
|
75
|
+
#
|
|
76
|
+
# Recursive, because serializers nest: an order carries a gift card, a
|
|
77
|
+
# customer carries addresses. A shallow reject would scrub the top level
|
|
78
|
+
# and hand the vendor whatever sat one layer down, which is the failure
|
|
79
|
+
# this filter exists to prevent.
|
|
80
|
+
#
|
|
81
|
+
# @param attributes [Hash]
|
|
82
|
+
# @return [Hash]
|
|
83
|
+
def sanitize(value, resource = nil)
|
|
84
|
+
case value
|
|
85
|
+
when Hash
|
|
86
|
+
exempt = LINK_SAFE_ATTRIBUTES.fetch(resource.to_s, [])
|
|
87
|
+
|
|
88
|
+
value.to_h.stringify_keys.each_with_object({}) do |(key, nested), result|
|
|
89
|
+
link_safe = exempt.include?(key)
|
|
90
|
+
|
|
91
|
+
next if key.match?(SECRET_PATTERN) || SECRET_ASSOCIATIONS.include?(key)
|
|
92
|
+
next if SECRET_ATTRIBUTES.include?(key) && !link_safe
|
|
93
|
+
next if credential_value?(nested) && !link_safe
|
|
94
|
+
|
|
95
|
+
# Only the record's own keys are exempt. A nested association
|
|
96
|
+
# carries its own rules, so recursion drops the exemption.
|
|
97
|
+
result[key] = sanitize(nested)
|
|
98
|
+
end
|
|
99
|
+
when Array
|
|
100
|
+
# Elements are filtered too, not just hash values: a customer's or
|
|
101
|
+
# order's tags are caller-controlled and serialized as a bare
|
|
102
|
+
# string array, so a tag holding a token-bearing URL would
|
|
103
|
+
# otherwise pass straight through.
|
|
104
|
+
value.reject { |element| credential_value?(element) }.map { |element| sanitize(element) }
|
|
105
|
+
else
|
|
106
|
+
value
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @param entry [Spree::AgentTools::ResourceMap::Entry]
|
|
111
|
+
# @param record [ActiveRecord::Base]
|
|
112
|
+
# @param attributes [Hash, nil] an already-sanitized serializer hash,
|
|
113
|
+
# for a caller that needs the full record too — serializing twice
|
|
114
|
+
# means running every association query twice
|
|
115
|
+
# @return [Hash]
|
|
116
|
+
def call(entry:, record:, attributes: nil)
|
|
117
|
+
attributes ||= serialize(entry, record)
|
|
118
|
+
title = title_for(record, attributes)
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
id: record.prefixed_id,
|
|
122
|
+
resource: entry.key,
|
|
123
|
+
title: title,
|
|
124
|
+
subtitle: subtitle_for(attributes, title),
|
|
125
|
+
status: attributes['status'],
|
|
126
|
+
image_url: attributes['thumbnail_url'],
|
|
127
|
+
# The dashboard route this record lives at, so the panel links
|
|
128
|
+
# straight to it instead of the merchant hunting for it.
|
|
129
|
+
path: entry.dashboard_path_for(record)
|
|
130
|
+
}.compact
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# A summary from what the Admin API returned, rather than from a
|
|
134
|
+
# record.
|
|
135
|
+
#
|
|
136
|
+
# The payload is already the serializer's own output, so there is no
|
|
137
|
+
# second serialization and no second set of association queries. It
|
|
138
|
+
# still passes the credential filter: the API may hand an authorised
|
|
139
|
+
# admin a gift card code, and a model is not an authorised admin.
|
|
140
|
+
#
|
|
141
|
+
# @param entry [Spree::AgentTools::ResourceMap::Entry]
|
|
142
|
+
# @param payload [Hash] one record as the endpoint rendered it
|
|
143
|
+
# @return [Hash]
|
|
144
|
+
def from_payload(entry:, payload:)
|
|
145
|
+
attributes = sanitize(payload.to_h.stringify_keys, entry.key)
|
|
146
|
+
title = title_from(attributes)
|
|
147
|
+
|
|
148
|
+
{
|
|
149
|
+
id: attributes['id'],
|
|
150
|
+
resource: entry.key,
|
|
151
|
+
title: title,
|
|
152
|
+
subtitle: subtitle_for(attributes, title),
|
|
153
|
+
status: attributes['status'],
|
|
154
|
+
image_url: attributes['thumbnail_url'],
|
|
155
|
+
path: dashboard_path_from(entry, attributes['id'])
|
|
156
|
+
}.compact
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
# The same preference order as a record's title, read from the payload.
|
|
162
|
+
def title_from(attributes)
|
|
163
|
+
%w[name number label title email sku].each do |field|
|
|
164
|
+
value = attributes[field]
|
|
165
|
+
return value.to_s if value.is_a?(String) && value.present?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
attributes['id'].to_s
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def dashboard_path_from(entry, id)
|
|
172
|
+
return if entry.dashboard_path.blank? || id.blank?
|
|
173
|
+
|
|
174
|
+
format(entry.dashboard_path, id: id)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Whether a value is itself a credential — a link that authenticates on
|
|
178
|
+
# nothing but the link.
|
|
179
|
+
def credential_value?(value)
|
|
180
|
+
value.is_a?(String) && value.match?(SECRET_VALUE_PATTERN)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def serialize(entry, record)
|
|
184
|
+
sanitize(entry.serializer_class.new(record).to_h)
|
|
185
|
+
rescue StandardError => e
|
|
186
|
+
Rails.logger.warn("[Spree] #{entry.key} serializer failed: #{e.class}: #{e.message}")
|
|
187
|
+
{}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def title_for(record, attributes)
|
|
191
|
+
attributes['name'].presence ||
|
|
192
|
+
attributes['number'].presence ||
|
|
193
|
+
attributes['email'].presence ||
|
|
194
|
+
record.try(:name).presence ||
|
|
195
|
+
record.try(:number).presence ||
|
|
196
|
+
record.to_s
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def subtitle_for(attributes, title)
|
|
200
|
+
value = SUBTITLE_FIELDS.filter_map { |field| formatted(attributes[field]) }.first
|
|
201
|
+
# A subtitle that repeats the title (a customer whose name IS their
|
|
202
|
+
# email) is a wasted line.
|
|
203
|
+
value unless value == title
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# A subtitle is one short line a model reads aloud, so only a scalar
|
|
207
|
+
# will do. A variant's `price` serializes as an object, and `to_s` on
|
|
208
|
+
# it printed the whole Ruby hash — amount, currency, cents and all —
|
|
209
|
+
# where "£89.99" belonged.
|
|
210
|
+
def formatted(value)
|
|
211
|
+
return if value.blank?
|
|
212
|
+
return unless value.is_a?(String) || value.is_a?(Numeric) || value.is_a?(Symbol)
|
|
213
|
+
|
|
214
|
+
value.to_s
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|