spree_core 6.0.0.rc3 → 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.
Files changed (118) hide show
  1. checksums.yaml +4 -4
  2. data/app/mailers/spree/email_template_mailer.rb +5 -2
  3. data/app/models/concerns/spree/has_external_references.rb +7 -6
  4. data/app/models/concerns/spree/preference_schema/json_schema.rb +8 -1
  5. data/app/models/concerns/spree/purchase/taxation.rb +44 -3
  6. data/app/models/concerns/spree/translatable_resource.rb +49 -0
  7. data/app/models/spree/cart.rb +1 -0
  8. data/app/models/spree/delivery_rate.rb +12 -4
  9. data/app/models/spree/delivery_rate_provider/base.rb +1 -1
  10. data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
  11. data/app/models/spree/fulfillment.rb +1 -0
  12. data/app/models/spree/gateway/bogus.rb +7 -0
  13. data/app/models/spree/line_item.rb +3 -8
  14. data/app/models/spree/oauth_access_grant.rb +11 -0
  15. data/app/models/spree/oauth_access_token.rb +16 -0
  16. data/app/models/spree/oauth_application.rb +97 -0
  17. data/app/models/spree/oauth_consent_request.rb +23 -0
  18. data/app/models/spree/oauth_protected_resource.rb +21 -0
  19. data/app/models/spree/oauth_redirect.rb +12 -0
  20. data/app/models/spree/option_type.rb +5 -0
  21. data/app/models/spree/option_value.rb +5 -0
  22. data/app/models/spree/order.rb +5 -1
  23. data/app/models/spree/payment.rb +14 -2
  24. data/app/models/spree/payment_capture_event.rb +2 -0
  25. data/app/models/spree/policy.rb +6 -0
  26. data/app/models/spree/price.rb +16 -33
  27. data/app/models/spree/price_rule.rb +3 -16
  28. data/app/models/spree/price_rules/market_rule.rb +0 -6
  29. data/app/models/spree/pricing_provider/base.rb +1 -1
  30. data/app/models/spree/product.rb +1 -1
  31. data/app/models/spree/purchase_order_item.rb +1 -1
  32. data/app/models/spree/return.rb +1 -0
  33. data/app/models/spree/search_provider/database.rb +1 -1
  34. data/app/models/spree/stock/estimator.rb +4 -20
  35. data/app/models/spree/store.rb +8 -0
  36. data/app/models/spree/tax_exemption/item_override.rb +14 -0
  37. data/app/models/spree/tax_exemption.rb +26 -0
  38. data/app/models/spree/tax_exemption_certificate.rb +13 -2
  39. data/app/models/spree/tax_exemption_reason.rb +62 -0
  40. data/app/models/spree/tax_provider/base.rb +24 -4
  41. data/app/models/spree/tax_provider/internal.rb +36 -29
  42. data/app/models/spree/tax_rate.rb +9 -35
  43. data/app/models/spree/translations/batch.rb +14 -4
  44. data/app/services/spree/carts/price_items.rb +2 -8
  45. data/app/services/spree/carts/split_by_seller.rb +6 -1
  46. data/app/services/spree/carts/update.rb +34 -7
  47. data/app/services/spree/email_templates/preview.rb +24 -3
  48. data/app/services/spree/seeds/store_resources.rb +1 -0
  49. data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
  50. data/app/services/spree/tax/resolve_exemptions.rb +11 -2
  51. data/app/services/spree/update_check.rb +8 -1
  52. data/app/tools/spree/agent_tools/create_export.rb +116 -0
  53. data/app/tools/spree/agent_tools/create_import.rb +117 -0
  54. data/app/tools/spree/agent_tools/create_media.rb +81 -0
  55. data/app/tools/spree/agent_tools/create_resource.rb +36 -0
  56. data/app/tools/spree/agent_tools/curate_products.rb +90 -0
  57. data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
  58. data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
  59. data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
  60. data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
  61. data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
  62. data/app/tools/spree/agent_tools/get_resource.rb +97 -0
  63. data/app/tools/spree/agent_tools/import_tool.rb +80 -0
  64. data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
  65. data/app/tools/spree/agent_tools/query_report.rb +124 -0
  66. data/app/tools/spree/agent_tools/resource_write.rb +99 -0
  67. data/app/tools/spree/agent_tools/search_resources.rb +155 -0
  68. data/app/tools/spree/agent_tools/set_stock.rb +52 -0
  69. data/app/tools/spree/agent_tools/update_resource.rb +42 -0
  70. data/app/tools/spree/agent_tools/upload_file.rb +104 -0
  71. data/app/workflows/spree/carts/complete.rb +115 -2
  72. data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
  73. data/app/workflows/spree/orders/cancel.rb +8 -0
  74. data/app/workflows/spree/orders/complete.rb +8 -0
  75. data/app/{services → workflows}/spree/orders/create.rb +9 -9
  76. data/app/workflows/spree/purchase_orders/create.rb +3 -0
  77. data/app/workflows/spree/returns/refund.rb +4 -0
  78. data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
  79. data/app/workflows/spree/variants/create.rb +7 -4
  80. data/config/locales/en.yml +14 -0
  81. data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
  82. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
  83. data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
  84. data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
  85. data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
  86. data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
  87. data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
  88. data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
  89. data/db/sample_data/orders.rb +1 -1
  90. data/lib/spree/agent_tool.rb +188 -0
  91. data/lib/spree/agent_tools/api_dispatch.rb +204 -0
  92. data/lib/spree/agent_tools/context.rb +191 -0
  93. data/lib/spree/agent_tools/default_catalog.rb +180 -0
  94. data/lib/spree/agent_tools/membership_map.rb +61 -0
  95. data/lib/spree/agent_tools/record_summary.rb +219 -0
  96. data/lib/spree/agent_tools/registry.rb +131 -0
  97. data/lib/spree/agent_tools/resource_map.rb +352 -0
  98. data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
  99. data/lib/spree/agent_tools/workflow_schema.rb +279 -0
  100. data/lib/spree/agent_tools/workflow_tool.rb +654 -0
  101. data/lib/spree/agent_tools.rb +24 -0
  102. data/lib/spree/core/configuration.rb +1 -1
  103. data/lib/spree/core/controller_helpers/store.rb +3 -13
  104. data/lib/spree/core/dependencies.rb +9 -0
  105. data/lib/spree/core/engine.rb +35 -1
  106. data/lib/spree/core/model_extensions.rb +111 -0
  107. data/lib/spree/core/permissions/default_catalog.rb +6 -1
  108. data/lib/spree/core/pricing/price_resolution.rb +4 -4
  109. data/lib/spree/core/version.rb +1 -1
  110. data/lib/spree/core.rb +27 -2
  111. data/lib/spree/service_module.rb +11 -0
  112. data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
  113. data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
  114. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
  115. data/lib/tasks/markets.rake +47 -0
  116. data/lib/tasks/tax_exemption_reasons.rake +48 -0
  117. metadata +67 -7
  118. 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