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,67 @@
|
|
|
1
|
+
# OAuth 2.1 authorization server tables. Separate from any OAuth Spree
|
|
2
|
+
# carried before 5.4: those served password and client-credentials grants for
|
|
3
|
+
# the legacy API, and nothing migrates forward.
|
|
4
|
+
class CreateSpreeOauthTables < ActiveRecord::Migration[8.1]
|
|
5
|
+
def change
|
|
6
|
+
create_table :spree_oauth_applications do |t|
|
|
7
|
+
t.string :name, null: false
|
|
8
|
+
t.string :uid, null: false
|
|
9
|
+
# Null for a public client, which is every MCP client: they run in a
|
|
10
|
+
# browser or on a vendor's server and cannot hold a secret, which is
|
|
11
|
+
# why PKCE is mandatory rather than optional here.
|
|
12
|
+
t.string :secret
|
|
13
|
+
t.text :redirect_uri
|
|
14
|
+
t.string :scopes, null: false, default: ''
|
|
15
|
+
t.boolean :confidential, null: false, default: false
|
|
16
|
+
t.references :store, null: false, index: true
|
|
17
|
+
t.timestamps
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
add_index :spree_oauth_applications, :uid, unique: true
|
|
21
|
+
|
|
22
|
+
create_table :spree_oauth_access_grants do |t|
|
|
23
|
+
t.references :resource_owner, polymorphic: true, null: false, index: true
|
|
24
|
+
t.references :application, null: false, index: true
|
|
25
|
+
t.string :token, null: false
|
|
26
|
+
t.integer :expires_in, null: false
|
|
27
|
+
t.text :redirect_uri
|
|
28
|
+
t.string :scopes, null: false, default: ''
|
|
29
|
+
t.datetime :revoked_at
|
|
30
|
+
t.string :code_challenge
|
|
31
|
+
t.string :code_challenge_method
|
|
32
|
+
# RFC 8707: what the grant was requested for, carried to the token so
|
|
33
|
+
# the audience can be checked on every call. The column name is
|
|
34
|
+
# Doorkeeper's — its `resource_indicators_supported?` detects the
|
|
35
|
+
# feature by this column's presence.
|
|
36
|
+
t.text :resource
|
|
37
|
+
t.timestamps
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
add_index :spree_oauth_access_grants, :token, unique: true
|
|
41
|
+
|
|
42
|
+
create_table :spree_oauth_access_tokens do |t|
|
|
43
|
+
t.references :resource_owner, polymorphic: true, index: true
|
|
44
|
+
t.references :application, index: true
|
|
45
|
+
# Text rather than string: tokens are hashed, and a hash outgrows 255
|
|
46
|
+
# characters under some configurations.
|
|
47
|
+
t.text :token, null: false
|
|
48
|
+
t.text :refresh_token
|
|
49
|
+
t.integer :expires_in
|
|
50
|
+
t.datetime :revoked_at
|
|
51
|
+
t.string :scopes
|
|
52
|
+
t.string :previous_refresh_token, null: false, default: ''
|
|
53
|
+
t.text :resource
|
|
54
|
+
t.timestamps
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Hashed tokens are long, so both lookup indexes cover a prefix on MySQL,
|
|
58
|
+
# where an index over a full-length text column is rejected outright.
|
|
59
|
+
if connection.adapter_name.match?(/mysql/i)
|
|
60
|
+
add_index :spree_oauth_access_tokens, :token, unique: true, length: 255
|
|
61
|
+
add_index :spree_oauth_access_tokens, :refresh_token, unique: true, length: 255
|
|
62
|
+
else
|
|
63
|
+
add_index :spree_oauth_access_tokens, :token, unique: true
|
|
64
|
+
add_index :spree_oauth_access_tokens, :refresh_token, unique: true
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Whether tax sits inside a price is the market's setting now, and the column is
|
|
2
|
+
# read once by `spree:markets:backfill_tax_inclusive`. New rates leave it empty,
|
|
3
|
+
# so that step, which every later upgrade runs again, never mistakes one for a
|
|
4
|
+
# rate whose tax was added on top. The column is dropped in 6.1.
|
|
5
|
+
class RemoveDefaultFromSpreeTaxRatesIncludedInPrice < ActiveRecord::Migration[8.1]
|
|
6
|
+
def change
|
|
7
|
+
change_column_default :spree_tax_rates, :included_in_price, from: false, to: nil
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class CreateSpreeTaxExemptionReasons < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :spree_tax_exemption_reasons do |t|
|
|
4
|
+
t.references :store, null: false, index: false
|
|
5
|
+
t.string :name, null: false
|
|
6
|
+
t.string :code, null: false
|
|
7
|
+
t.boolean :active, null: false, default: true
|
|
8
|
+
if t.respond_to?(:jsonb)
|
|
9
|
+
t.jsonb :metadata
|
|
10
|
+
else
|
|
11
|
+
t.json :metadata
|
|
12
|
+
end
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
add_index :spree_tax_exemption_reasons, [:store_id, :name], unique: true,
|
|
17
|
+
name: 'idx_tax_exemption_reasons_on_store_and_name'
|
|
18
|
+
add_index :spree_tax_exemption_reasons, [:store_id, :code], unique: true,
|
|
19
|
+
name: 'idx_tax_exemption_reasons_on_store_and_code'
|
|
20
|
+
end
|
|
21
|
+
end
|
data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class AddTaxExemptionReasonToSpreeTaxExemptionCertificates < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
# Nullable, and reason_code kept: certificates recorded before reasons
|
|
4
|
+
# existed are linked by `spree:upgrade:tax_exemption_reasons`, which reads
|
|
5
|
+
# their reason_code.
|
|
6
|
+
add_reference :spree_tax_exemption_certificates, :tax_exemption_reason
|
|
7
|
+
change_column_null :spree_tax_exemption_certificates, :reason_code, true
|
|
8
|
+
end
|
|
9
|
+
end
|
data/db/sample_data/orders.rb
CHANGED
|
@@ -98,7 +98,7 @@ if tax_rate
|
|
|
98
98
|
).first_or_create! do |tax_line|
|
|
99
99
|
tax_line.amount = 0
|
|
100
100
|
tax_line.rate = tax_rate.amount
|
|
101
|
-
tax_line.included =
|
|
101
|
+
tax_line.included = order.tax_inclusive?
|
|
102
102
|
tax_line.provider_id = 'internal'
|
|
103
103
|
end
|
|
104
104
|
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
# One entry in the agent-tool registry ({Spree.admin_agent_tools}).
|
|
3
|
+
#
|
|
4
|
+
# A tool is a plain Spree object: a name, a description the model reads, a
|
|
5
|
+
# JSON parameter schema, the permission key that gates it, whether it
|
|
6
|
+
# mutates the store, and an executor. Nothing here knows which client is
|
|
7
|
+
# asking — the MCP server and the dashboard assistant are both adapters
|
|
8
|
+
# over this one registry, so a tool registered once is offered by both.
|
|
9
|
+
#
|
|
10
|
+
# Subclasses implement {#call} and declare their metadata with the class-level
|
|
11
|
+
# DSL:
|
|
12
|
+
#
|
|
13
|
+
# class Spree::AgentTools::SearchResources < Spree::AgentTool
|
|
14
|
+
# tool_name 'search_resources'
|
|
15
|
+
# description 'Search store records.'
|
|
16
|
+
# permission 'read_products'
|
|
17
|
+
# param :resource, description: 'What to search', required: true
|
|
18
|
+
#
|
|
19
|
+
# def call(resource:)
|
|
20
|
+
# # ...
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
class AgentTool
|
|
24
|
+
# Declarations live in class attributes rather than plain class-level
|
|
25
|
+
# instance variables so a subclass inherits them. Subclassing a tool to
|
|
26
|
+
# tweak it is the documented extension path, and with bare ivars the
|
|
27
|
+
# subclass would come back `mutating? == false` — writing to the store
|
|
28
|
+
# immediately, with no approval card, no permission gate and no argument
|
|
29
|
+
# validation. Inheritance here is a safety property, not a convenience.
|
|
30
|
+
class_attribute :declared_tool_name, instance_accessor: false
|
|
31
|
+
class_attribute :declared_description, instance_accessor: false
|
|
32
|
+
class_attribute :declared_permission, instance_accessor: false
|
|
33
|
+
class_attribute :declared_mutating, instance_accessor: false, default: false
|
|
34
|
+
class_attribute :declared_audience, instance_accessor: false, default: :admin
|
|
35
|
+
class_attribute :declared_params, instance_accessor: false, default: {}.freeze
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# @return [String] the name the model calls this tool by
|
|
39
|
+
def tool_name(value = nil)
|
|
40
|
+
self.declared_tool_name = value if value
|
|
41
|
+
declared_tool_name
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [String] what the tool does, read by the model when choosing
|
|
45
|
+
def description(value = nil)
|
|
46
|
+
self.declared_description = value if value
|
|
47
|
+
declared_description
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# RBAC permission key required to use this tool. A user without it never
|
|
51
|
+
# sees the tool in the list handed to the model.
|
|
52
|
+
#
|
|
53
|
+
# @return [String]
|
|
54
|
+
def permission(value = nil)
|
|
55
|
+
self.declared_permission = value if value
|
|
56
|
+
declared_permission
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Which surface this tool belongs to: `:admin` for the back office,
|
|
60
|
+
# `:store` for the storefront.
|
|
61
|
+
#
|
|
62
|
+
# Declared on the tool rather than inferred from the registry it was
|
|
63
|
+
# pushed into, so filing one in the wrong place is refused at
|
|
64
|
+
# registration. It matters most for the tools that carry no permission
|
|
65
|
+
# of their own — they are gated per resource, and a blank permission
|
|
66
|
+
# reads as "anyone may see this".
|
|
67
|
+
#
|
|
68
|
+
# @param value [Symbol, nil]
|
|
69
|
+
# @return [Symbol]
|
|
70
|
+
def audience(value = nil)
|
|
71
|
+
self.declared_audience = value.to_sym if value
|
|
72
|
+
declared_audience
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Declares that this tool changes store data, so the turn halts for
|
|
76
|
+
# human approval instead of executing.
|
|
77
|
+
#
|
|
78
|
+
# @return [void]
|
|
79
|
+
def mutating!
|
|
80
|
+
self.declared_mutating = true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def mutating?
|
|
85
|
+
declared_mutating == true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @param name [Symbol]
|
|
89
|
+
# @param type [Symbol] JSON schema type
|
|
90
|
+
# @param description [String]
|
|
91
|
+
# @param required [Boolean]
|
|
92
|
+
# @param items [Symbol] what an array holds — a JSON Schema array without
|
|
93
|
+
# it is ambiguous, and a client that validates arguments against the
|
|
94
|
+
# schema rejects the call before the tool ever runs
|
|
95
|
+
# @return [void]
|
|
96
|
+
def param(name, type: :string, description: nil, required: false, items: nil)
|
|
97
|
+
# Reassigned rather than mutated, so declaring a param on a subclass
|
|
98
|
+
# does not reach back and change the parent's list.
|
|
99
|
+
self.declared_params = declared_params.merge(
|
|
100
|
+
name => { type: type, description: description, required: required, items: items }
|
|
101
|
+
).freeze
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Hash{Symbol => Hash}]
|
|
105
|
+
def params
|
|
106
|
+
declared_params
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [Spree::AgentTools::Context] who is asking, and where
|
|
111
|
+
attr_reader :context
|
|
112
|
+
|
|
113
|
+
# @param context [Spree::AgentTools::Context]
|
|
114
|
+
def initialize(context)
|
|
115
|
+
@context = context
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
delegate :tool_name, :description, :permission, :mutating?, :params, to: :class
|
|
119
|
+
|
|
120
|
+
# Runs the tool. Subclasses implement this with keyword arguments matching
|
|
121
|
+
# their declared params.
|
|
122
|
+
#
|
|
123
|
+
# @return [Hash] the result handed back to the model
|
|
124
|
+
def call(**)
|
|
125
|
+
raise NotImplementedError, 'Agent tools must implement #call'
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# One line saying what approving this call would do. It is the entire
|
|
129
|
+
# approval card — the merchant decides on this sentence and nothing else,
|
|
130
|
+
# so every mutating tool should override it with something that reads
|
|
131
|
+
# like an instruction ("Set Blue Shirt to draft"), naming the record
|
|
132
|
+
# rather than its id.
|
|
133
|
+
#
|
|
134
|
+
# The fallback spells the arguments out rather than dumping JSON, so a
|
|
135
|
+
# tool that forgets to override is merely terse instead of unreadable.
|
|
136
|
+
#
|
|
137
|
+
# @param arguments [Hash]
|
|
138
|
+
# @return [String]
|
|
139
|
+
def summary(arguments)
|
|
140
|
+
described = arguments.map { |key, value| "#{key.to_s.humanize.downcase} #{value}" }.join(', ')
|
|
141
|
+
|
|
142
|
+
[tool_name.humanize, described.presence].compact.join(': ')
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Whether the asking user may use this tool at all.
|
|
146
|
+
#
|
|
147
|
+
# @return [Boolean]
|
|
148
|
+
def permitted?
|
|
149
|
+
context.permitted?(permission)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# A resource the map does not hold, answered with the ones it does.
|
|
153
|
+
#
|
|
154
|
+
# Every tool that takes a resource key refuses the same way: naming what is
|
|
155
|
+
# available is what lets the model correct itself in one step instead of
|
|
156
|
+
# guessing again, and a caller only ever sees resources its own credential
|
|
157
|
+
# may read.
|
|
158
|
+
#
|
|
159
|
+
# @param key [String]
|
|
160
|
+
# @return [Hash]
|
|
161
|
+
def unknown_resource(key)
|
|
162
|
+
{
|
|
163
|
+
error: "Unknown resource #{key.to_s.inspect}. Call describe_resource for the list.",
|
|
164
|
+
available: Spree::AgentTools::ResourceMap.available_for(context).map(&:key)
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
protected
|
|
169
|
+
|
|
170
|
+
# Refuses unless this admin may take `action` on `record`.
|
|
171
|
+
#
|
|
172
|
+
# Holding `write_products` says an admin may edit products; it does not
|
|
173
|
+
# say they may edit *this* one. A host app can narrow that with a
|
|
174
|
+
# record-level rule, and the Admin API honours it — so any tool that
|
|
175
|
+
# changes a record must ask here first. Returns an error the model reads
|
|
176
|
+
# rather than raising, so the assistant explains itself instead of the
|
|
177
|
+
# turn dying.
|
|
178
|
+
#
|
|
179
|
+
# @param action [Symbol]
|
|
180
|
+
# @param record [Object]
|
|
181
|
+
# @return [Hash, nil] an error result to return, or nil when allowed
|
|
182
|
+
def unauthorized(action, record)
|
|
183
|
+
return if context.can?(action, record)
|
|
184
|
+
|
|
185
|
+
{ error: "You do not have permission to #{action} this #{record.class.name.demodulize.underscore.humanize.downcase}." }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module AgentTools
|
|
3
|
+
# Runs an Admin API operation for a tool, in this process.
|
|
4
|
+
#
|
|
5
|
+
# A tool names an operation; this is what reaches it. The request goes
|
|
6
|
+
# through the whole stack — routing, authentication, the controller, the
|
|
7
|
+
# serializer, the error format — so a tool reaches data the way any other
|
|
8
|
+
# client does, and a guard added to the API protects agents without anyone
|
|
9
|
+
# copying it here.
|
|
10
|
+
#
|
|
11
|
+
# Deliberately not HTTP to itself. A socket back to the same application
|
|
12
|
+
# holds one web thread waiting on another, so a few concurrent tool calls
|
|
13
|
+
# on a small pool deadlock the process, and it buys TLS, DNS and timeouts
|
|
14
|
+
# for nothing.
|
|
15
|
+
class ApiDispatch
|
|
16
|
+
# What a dispatched call answers with.
|
|
17
|
+
Response = Struct.new(:status, :body, :headers, :raw, keyword_init: true) do
|
|
18
|
+
# What the endpoint actually sent, for an operation whose answer is a
|
|
19
|
+
# file rather than a record.
|
|
20
|
+
#
|
|
21
|
+
# @return [String, nil]
|
|
22
|
+
def content
|
|
23
|
+
raw
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [String, nil]
|
|
27
|
+
def content_type
|
|
28
|
+
headers.to_h.find { |name, _| name.to_s.casecmp('content-type').zero? }&.last
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Boolean]
|
|
32
|
+
def success?
|
|
33
|
+
status.between?(200, 299)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The error the API itself rendered, in its own words. A tool repeats
|
|
37
|
+
# this rather than inventing a message, so a refusal reads the same
|
|
38
|
+
# however it was reached.
|
|
39
|
+
#
|
|
40
|
+
# @return [String, nil]
|
|
41
|
+
def error_message
|
|
42
|
+
return if success?
|
|
43
|
+
|
|
44
|
+
error = body.is_a?(Hash) ? body['error'] : nil
|
|
45
|
+
return error if error.is_a?(String)
|
|
46
|
+
return error['message'] if error.is_a?(Hash) && error['message'].present?
|
|
47
|
+
|
|
48
|
+
"The request failed (#{status})."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Hash, Array, nil]
|
|
52
|
+
def data
|
|
53
|
+
body.is_a?(Hash) && body.key?('data') ? body['data'] : body
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Hash, nil] pagination, when the operation is a listing
|
|
57
|
+
def meta
|
|
58
|
+
body['meta'] if body.is_a?(Hash)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Only what the Admin API reads. Anything else a client sent is the MCP
|
|
63
|
+
# request's business, not the operation's.
|
|
64
|
+
FORWARDED_HEADERS = %w[HTTP_AUTHORIZATION HTTP_X_SPREE_API_KEY HTTP_HOST].freeze
|
|
65
|
+
|
|
66
|
+
# @param context [Spree::AgentTools::Context]
|
|
67
|
+
def initialize(context)
|
|
68
|
+
@context = context
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param method [Symbol] :get, :post, :patch, :delete
|
|
72
|
+
# @param path [String] the operation's full path, already interpolated —
|
|
73
|
+
# the resource map's paths are absolute (`/api/v3/admin/products`)
|
|
74
|
+
# @param params [Hash] query parameters for a read
|
|
75
|
+
# @param body [Hash, nil] the request body for a write. A value that
|
|
76
|
+
# responds to `read` makes the request multipart, which is how the
|
|
77
|
+
# upload endpoint takes bytes.
|
|
78
|
+
# @return [Response]
|
|
79
|
+
def call(method:, path:, params: {}, body: nil)
|
|
80
|
+
env = build_env(method, path, params, body)
|
|
81
|
+
|
|
82
|
+
# `Spree::Current` is an `ActiveSupport::CurrentAttributes`, which does
|
|
83
|
+
# NOT reset for a request made inside another request — the executor
|
|
84
|
+
# is re-entrant. Measured on this branch before this class existed: an
|
|
85
|
+
# inner call left the outer store in place and overwrote its currency.
|
|
86
|
+
#
|
|
87
|
+
# So the outer state is copied, cleared so the inner request resolves
|
|
88
|
+
# its own from its own headers, and put back whatever happened. The
|
|
89
|
+
# restore has to sit in this method's own `ensure`: wrapped in a
|
|
90
|
+
# helper that yields, it ran before the executor finished unwinding
|
|
91
|
+
# and the outer values were lost again.
|
|
92
|
+
saved = Spree::Current.attributes.to_a
|
|
93
|
+
# `RequestStore` is per-request too, and the inner request's middleware
|
|
94
|
+
# clears it on the way out — leaving `RequestStore.store` nil, which
|
|
95
|
+
# the store scope guard indexes into on the outer request's next
|
|
96
|
+
# query. Saved unconditionally, including when it is already empty:
|
|
97
|
+
# restoring an empty hash is what keeps it a hash.
|
|
98
|
+
saved_request_store = defined?(RequestStore) ? RequestStore.store.to_h.dup : nil
|
|
99
|
+
|
|
100
|
+
begin
|
|
101
|
+
status, headers, rack_body = Rails.application.call(env)
|
|
102
|
+
text = read(rack_body)
|
|
103
|
+
Response.new(status: status, headers: headers, body: parse(text), raw: text)
|
|
104
|
+
ensure
|
|
105
|
+
# The executor flushes `ActiveSupport::ExecutionContext` as the
|
|
106
|
+
# inner request unwinds, and that is where `CurrentAttributes` keeps
|
|
107
|
+
# its per-thread instances — so the outer request's next read of
|
|
108
|
+
# `Spree::Current` found no registry and raised inside Rails. A
|
|
109
|
+
# frame has to be pushed back BEFORE the values are written, or the
|
|
110
|
+
# writes land in a frame that is then replaced and read as nil.
|
|
111
|
+
ActiveSupport::ExecutionContext.push if defined?(ActiveSupport::ExecutionContext)
|
|
112
|
+
saved.each { |name, value| Spree::Current.public_send(:"#{name}=", value) }
|
|
113
|
+
RequestStore.store = saved_request_store unless saved_request_store.nil?
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def build_env(method, path, params, body)
|
|
120
|
+
query = params.present? ? Rack::Utils.build_nested_query(stringify(params)) : nil
|
|
121
|
+
|
|
122
|
+
env = Rack::MockRequest.env_for(
|
|
123
|
+
path,
|
|
124
|
+
method: method.to_s.upcase,
|
|
125
|
+
params: query,
|
|
126
|
+
**payload_env(body)
|
|
127
|
+
).compact
|
|
128
|
+
|
|
129
|
+
forwarded.each { |name, value| env[name] = value }
|
|
130
|
+
env['HTTP_ACCEPT'] = 'application/json'
|
|
131
|
+
env
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# JSON unless the body carries an IO, in which case the request is
|
|
135
|
+
# multipart — the shape `POST /files` reads bytes from, and the only
|
|
136
|
+
# way to hand it a file without a second request from outside.
|
|
137
|
+
def payload_env(body)
|
|
138
|
+
return {} if body.blank?
|
|
139
|
+
|
|
140
|
+
file = body.find { |_name, value| value.respond_to?(:read) }
|
|
141
|
+
return { input: JSON.generate(body), 'CONTENT_TYPE' => 'application/json' } if file.nil?
|
|
142
|
+
|
|
143
|
+
boundary = "SpreeAgentTools#{SecureRandom.hex(16)}"
|
|
144
|
+
{
|
|
145
|
+
input: multipart_body(body, file, boundary),
|
|
146
|
+
'CONTENT_TYPE' => "multipart/form-data; boundary=#{boundary}"
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Written out rather than taken from `Rack::Test`, which is a test
|
|
151
|
+
# dependency of neither gem and would be missing from a real install.
|
|
152
|
+
def multipart_body(body, file, boundary)
|
|
153
|
+
name, io = file
|
|
154
|
+
filename = body['filename'].presence || 'file'
|
|
155
|
+
content_type = body['content_type'].presence || 'application/octet-stream'
|
|
156
|
+
|
|
157
|
+
parts = body.except(name).filter_map do |field, value|
|
|
158
|
+
next if value.nil?
|
|
159
|
+
|
|
160
|
+
"--#{boundary}\r\nContent-Disposition: form-data; name=\"#{field}\"\r\n\r\n#{value}\r\n"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
parts << "--#{boundary}\r\n" \
|
|
164
|
+
"Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n" \
|
|
165
|
+
"Content-Type: #{content_type}\r\n\r\n"
|
|
166
|
+
|
|
167
|
+
# Binary, because the bytes are: joining them into a UTF-8 string
|
|
168
|
+
# raises on the first image that is not valid UTF-8.
|
|
169
|
+
parts.join.dup.force_encoding(Encoding::BINARY) +
|
|
170
|
+
io.read.dup.force_encoding(Encoding::BINARY) +
|
|
171
|
+
"\r\n--#{boundary}--\r\n".dup.force_encoding(Encoding::BINARY)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The credential the caller authenticated with, repeated verbatim. Never
|
|
175
|
+
# an internal header that skips authentication: that would rebuild the
|
|
176
|
+
# second security boundary this class exists to remove.
|
|
177
|
+
def forwarded
|
|
178
|
+
@context.request_headers.slice(*FORWARDED_HEADERS)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def read(rack_body)
|
|
182
|
+
text = +''
|
|
183
|
+
rack_body.each { |chunk| text << chunk }
|
|
184
|
+
rack_body.close if rack_body.respond_to?(:close)
|
|
185
|
+
text
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A download answers with the file, not a record, so a body that is not
|
|
189
|
+
# JSON is not an error — it is kept as it came in {Response#content}.
|
|
190
|
+
def parse(text)
|
|
191
|
+
return if text.blank?
|
|
192
|
+
|
|
193
|
+
JSON.parse(text)
|
|
194
|
+
rescue JSON::ParserError
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Rack wants strings; a tool passes whatever the model sent.
|
|
199
|
+
def stringify(params)
|
|
200
|
+
params.to_h.deep_transform_keys(&:to_s)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|