commerce7-rails 0.2.0 → 0.4.0
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/README.md +67 -3
- data/app/controllers/commerce7/extension_controller.rb +7 -3
- data/app/services/commerce7/client.rb +67 -6
- data/lib/commerce7/configuration.rb +24 -0
- data/lib/commerce7/version.rb +1 -1
- data/lib/generators/commerce7/install/templates/commerce7.rb +6 -0
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '094411b4f2757ca97c279f68504a9a76b3c2aad6a8db04ae2176cc82c9868430'
|
|
4
|
+
data.tar.gz: 24b9fdb82f090f504c900ef2fe10bad35a709cb4c4b622bc0595b9a4c00f3bce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba304ffc1ab7da1e5b2a3d9a9c1cc295402b3135c0803301765d7aec47fad3a7789d82ff16c0d0313835bbe4065608d55c74148a70a3e5799450dc2e8bd378c7
|
|
7
|
+
data.tar.gz: bfcfca822c4438ae7d09d90f6c8cb2fb3512a72f60784295ca46b824ed42b76f2cf4181463821360e69259a00e34340e059984e42358ca49e0c063dd3154dff7
|
data/README.md
CHANGED
|
@@ -4,11 +4,34 @@ Rails building blocks for building a [Commerce7](https://www.commerce7.com/) App
|
|
|
4
4
|
|
|
5
5
|
This gem owns the Commerce7-protocol plumbing. Your app owns the business logic: your tenant model's own fields, what a webhook handler actually does, what your App Extension pages render.
|
|
6
6
|
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Rails 8.1.4 or later
|
|
10
|
+
- Faraday 2.14.4 or later
|
|
11
|
+
|
|
12
|
+
Both floors date from v0.3.0. Earlier versions allowed Rails 7.1+ and any Faraday 2.x.
|
|
13
|
+
|
|
14
|
+
**Why:** `json` 3.0 changed the signature of `JSON.parse`, and older Rails and Faraday releases call it in ways it no longer accepts. Nothing in your app has to request `json` 3 for this to happen. It arrives as a transitive dependency (rubocop 1.91 pulls it in, for one), and neither Rails nor Faraday caps `json` below 3, so Bundler will happily resolve a combination that breaks at runtime. The floors rule those combinations out.
|
|
15
|
+
|
|
16
|
+
- **Faraday 2.14.3 and earlier:** the JSON response middleware breaks, so every Commerce7 REST client response raises `Faraday::ParsingError: wrong number of arguments (given 2, expected 1)`.
|
|
17
|
+
- **Rails before 8.1.4:** `ActiveSupport::JSON.decode` breaks, so every `json`/`jsonb` column read raises. That includes the `raw_activation_payload` column the install generator creates. How it fails depends on the Rails line:
|
|
18
|
+
|
|
19
|
+
| Rails | With `json` 3 |
|
|
20
|
+
|---|---|
|
|
21
|
+
| 7.1.x, 8.0.x | `ArgumentError: unknown keyword: quirks_mode` |
|
|
22
|
+
| 7.2.x | JSON decoding works |
|
|
23
|
+
| 8.1.0 to 8.1.3.x | `ArgumentError: wrong number of arguments (given 2, expected 1)` |
|
|
24
|
+
| 8.1.4 and later | Works |
|
|
25
|
+
|
|
26
|
+
A gemspec can't say "7.2.x or 8.1.4 and later", so the floor is 8.1.4.
|
|
27
|
+
|
|
28
|
+
If you're stuck on an older Rails, stay on v0.2.1 and pin `json` below 3 in your app's Gemfile (`gem "json", "~> 2.21"`).
|
|
29
|
+
|
|
7
30
|
## Install
|
|
8
31
|
|
|
9
32
|
```ruby
|
|
10
33
|
# Gemfile
|
|
11
|
-
gem "commerce7-rails", github: "ERCubed/commerce7-rails", tag: "v0.
|
|
34
|
+
gem "commerce7-rails", github: "ERCubed/commerce7-rails", tag: "v0.3.0"
|
|
12
35
|
```
|
|
13
36
|
|
|
14
37
|
```
|
|
@@ -69,6 +92,19 @@ class Current < ActiveSupport::CurrentAttributes
|
|
|
69
92
|
end
|
|
70
93
|
```
|
|
71
94
|
|
|
95
|
+
### Limiting which resources the client can read
|
|
96
|
+
|
|
97
|
+
Set `allowed_resources` to the Commerce7 resources your app reads. `Commerce7::Client` then refuses any other resource before sending a request, through the generic methods and the named helpers alike:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
Commerce7.configure do |c|
|
|
101
|
+
# ...
|
|
102
|
+
c.allowed_resources = %w[club-membership order customer]
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
A resource is the first segment of an API path (`order` in `order/123`), so sub-paths of a listed resource are allowed. Keep the list matching the scopes registered for your app in Commerce7's Developer Center: Commerce7 enforces those scopes too, and this list is the app's own declaration of them, in code. Left unset (`nil`), every resource is allowed. The install generator starts new apps at `[]`, so each resource is added deliberately. Entries are checked when set, so a typo fails at boot.
|
|
107
|
+
|
|
72
108
|
## Routes
|
|
73
109
|
|
|
74
110
|
This gem mounts nothing — you declare routes exactly as you would for any in-app controller, just pointing at the gem's classes, so the URLs already registered in Commerce7's Developer Center (Install/Uninstall URLs, an App Extension's iframe src) stay stable and under your control:
|
|
@@ -133,7 +169,30 @@ production:
|
|
|
133
169
|
|
|
134
170
|
```ruby
|
|
135
171
|
client = Commerce7::Client.new(tenant)
|
|
136
|
-
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Any endpoint
|
|
175
|
+
|
|
176
|
+
The generic methods reach every read endpoint in Commerce7's API, with pagination, rate-limit retries, and the tenant header handled for you:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
client.each("club-membership") { |membership| ... } # paginates automatically
|
|
180
|
+
client.each("customer", lastName: "Smith") { |customer| ... } # filters pass through as query params
|
|
181
|
+
client.each("customer").first(10) # without a block, returns an Enumerator
|
|
182
|
+
client.fetch("customer", customer_id) # one record: GET customer/{id}
|
|
183
|
+
client.get("customer/#{customer_id}/address") # any other GET, returns the parsed body
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`each` reads records from the response key Commerce7 names after the resource: the path's last segment, pluralized and camelCased (`"club-membership"` reads `clubMemberships`). If an endpoint uses a different key, pass `key: "theKey"`. A response without the expected key raises `ApiError` rather than quietly yielding nothing. Filters can be keywords or a hash; `key` is reserved for this method, so pass a filter literally named `key` in the hash.
|
|
187
|
+
|
|
188
|
+
Paths must be relative (`"customer"`, `"order/123"`): segments of letters, digits, `-` and `_`. Anything else (a full URL, `..`, percent-encoding) raises `Commerce7::Client::InvalidRequestError` before a request is made, because a full URL would otherwise make Faraday send the request, App ID and Secret included, to that host. `fetch` applies the same rule to the id, which often comes from a URL param or webhook. `InvalidRequestError` is a `Client::Error`, so code that already rescues those treats a tampered id like any failed lookup. A resource outside `allowed_resources` (see [Configure](#limiting-which-resources-the-client-can-read)) raises it too.
|
|
189
|
+
|
|
190
|
+
### Named helpers
|
|
191
|
+
|
|
192
|
+
These wrap `each`/`fetch` for the resources the apps built on this gem use, with notes on what each record carries:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
client.each_club_membership { |membership| ... } # embeds the customer and club
|
|
137
196
|
client.each_customer { |customer| ... }
|
|
138
197
|
client.each_order { |order| ... }
|
|
139
198
|
client.each_order(orderPaidDate: "gte:2026-01-01") { |order| ... } # params pass through as filters
|
|
@@ -142,7 +201,11 @@ client.each_inventory_location { |location| ... }
|
|
|
142
201
|
client.fetch_order(order_id)
|
|
143
202
|
```
|
|
144
203
|
|
|
145
|
-
|
|
204
|
+
### Read only
|
|
205
|
+
|
|
206
|
+
The client only sends GET requests. There's deliberately no POST/PUT/DELETE: an app that writes to Commerce7 needs broader API permissions, changes its answer on Commerce7's security review, and needs care around retries and audit logging. Writes will be added when an app actually needs them.
|
|
207
|
+
|
|
208
|
+
Every call handles the 100 req/min rate limit (retries on 429 using `Retry-After` when present, exponential backoff otherwise), and raises `Commerce7::Client::AuthenticationError` / `RateLimitedError` / `ApiError` as appropriate.
|
|
146
209
|
|
|
147
210
|
`Commerce7::AccountClient` validates the staff JWT Commerce7 passes into an App Extension iframe — used internally by `Commerce7::ExtensionController`, but available directly if you need it.
|
|
148
211
|
|
|
@@ -153,6 +216,7 @@ This gem exists so every app built on it starts from a "Yes" on Commerce7's App
|
|
|
153
216
|
- **Server-to-server auth**: `Commerce7::BaseController` requires HTTP Basic Auth (your `webhook_credentials`) on every activation/deactivation/webhook POST, and audits both successful and failed attempts.
|
|
154
217
|
- **App Extension auth**: `Commerce7::ExtensionController` validates the staff JWT Commerce7 passes into every iframe load against Commerce7's own `/account/user` endpoint — a real error page on failure, never a bare status code.
|
|
155
218
|
- **PII**: `raw_activation_payload` (the installing staff member's name/email) is your model's column to encrypt — see Install above.
|
|
219
|
+
- **Least-privilege API access**: the client is read only (no POST/PUT/DELETE), refuses any resource outside `allowed_resources`, and rejects anything but a plain relative path or id before sending, so a tampered id or a full URL can never send your App ID and Secret anywhere but Commerce7.
|
|
156
220
|
- **Data deletion**: built in — soft-deactivate on uninstall, hard-delete after 30 days (`Commerce7::PurgeDeactivatedTenantsJob`).
|
|
157
221
|
- **Webhook auth + idempotency**: Basic Auth on every delivery; handlers are expected to be idempotent by construction, since Commerce7 exposes no delivery id to dedupe against.
|
|
158
222
|
- **Audit trail**: every security-relevant event (server auth success/failure, activation/deactivation, staff extension auth, webhook-driven dispatch, the post-uninstall purge) flows through your configured `audit` hook — user identity, event type, timestamp, success/failure, and origin.
|
|
@@ -18,15 +18,19 @@ module Commerce7
|
|
|
18
18
|
# a cross-site iframe POST where the session cookie may not travel).
|
|
19
19
|
protect_from_forgery with: :exception
|
|
20
20
|
|
|
21
|
-
before_action :authenticate_staff!
|
|
22
|
-
|
|
23
21
|
# Rails sends X-Frame-Options: SAMEORIGIN by default, which blocks
|
|
24
22
|
# Commerce7's admin panel (a different origin) from framing this page at
|
|
25
23
|
# all. Commerce7 doesn't publish a stable admin origin to scope a
|
|
26
24
|
# replacement CSP frame-ancestors to, so this just drops the blanket
|
|
27
25
|
# deny; a host app that wants a tighter CSP can add its own
|
|
28
26
|
# frame-ancestors directive once that origin is confirmed.
|
|
29
|
-
|
|
27
|
+
#
|
|
28
|
+
# A before_action, declared ahead of authenticate_staff!, rather than an
|
|
29
|
+
# after_action: when auth fails, authenticate_staff! renders the error
|
|
30
|
+
# page and halts the chain, and after_actions never run — which left
|
|
31
|
+
# exactly the page staff most need to see blocked inside the iframe.
|
|
32
|
+
before_action { response.headers.delete("X-Frame-Options") }
|
|
33
|
+
before_action :authenticate_staff!
|
|
30
34
|
|
|
31
35
|
rescue_from ActionController::ParameterMissing do |error|
|
|
32
36
|
render plain: error.message, status: :bad_request
|
|
@@ -15,6 +15,10 @@ module Commerce7
|
|
|
15
15
|
class AuthenticationError < Error; end
|
|
16
16
|
class RateLimitedError < Error; end
|
|
17
17
|
class ApiError < Error; end
|
|
18
|
+
# A path or id that isn't safe to send. A Client::Error, so callers that
|
|
19
|
+
# already rescue those (e.g. around a lookup by an id from a URL) handle a
|
|
20
|
+
# tampered value like any other failed lookup.
|
|
21
|
+
class InvalidRequestError < Error; end
|
|
18
22
|
|
|
19
23
|
# Trailing slash matters: Faraday/URI joins a relative path onto this by
|
|
20
24
|
# RFC 3986 merge rules, so without it "v1" gets treated as a filename and
|
|
@@ -22,6 +26,14 @@ module Commerce7
|
|
|
22
26
|
BASE_URL = "https://api.commerce7.com/v1/"
|
|
23
27
|
PAGE_SIZE = 50
|
|
24
28
|
MAX_RETRIES = 3
|
|
29
|
+
# A relative API path: segments of letters, digits, - and _, joined by
|
|
30
|
+
# single slashes. Never a full URL (Faraday would send the request, App
|
|
31
|
+
# ID/Secret included, to that host instead), never "." or ".." segments,
|
|
32
|
+
# and no "%", so nothing percent-encoded (e.g. %2e%2e for "..") can be
|
|
33
|
+
# decoded into a different path by the server.
|
|
34
|
+
PATH_FORMAT = %r{\A[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*\z}
|
|
35
|
+
# A record id, which becomes one path segment. Commerce7 ids are UUIDs.
|
|
36
|
+
ID_FORMAT = /\A[A-Za-z0-9_-]+\z/
|
|
25
37
|
|
|
26
38
|
def initialize(tenant, base_url: BASE_URL, sleeper: ->(seconds) { sleep(seconds) })
|
|
27
39
|
@app_id, @app_secret_key = Commerce7.configuration.app_credentials.call
|
|
@@ -76,18 +88,68 @@ module Commerce7
|
|
|
76
88
|
# orderId from Commerce7. Returns the order hash, which carries a
|
|
77
89
|
# top-level customerId same as club-membership's.
|
|
78
90
|
def fetch_order(order_id)
|
|
79
|
-
|
|
91
|
+
fetch("order", order_id)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Generic read access to any Commerce7 list endpoint, with the same
|
|
95
|
+
# pagination and rate-limit handling as the named methods above:
|
|
96
|
+
#
|
|
97
|
+
# client.each("club-membership") { |membership| ... }
|
|
98
|
+
# client.each("customer", lastName: "Smith") { |customer| ... }
|
|
99
|
+
# client.each("some-endpoint", key: "unusualKey") { |record| ... }
|
|
100
|
+
#
|
|
101
|
+
# Records are read from the response key Commerce7 names after the
|
|
102
|
+
# resource: the path's last segment, pluralized and camelCased
|
|
103
|
+
# ("club-membership" => "clubMemberships"). Pass `key:` when an endpoint
|
|
104
|
+
# differs. A response without that key raises ApiError rather than
|
|
105
|
+
# quietly yielding nothing. Filters can be keywords (as above) or a hash;
|
|
106
|
+
# `key` is the one name reserved for this method, so pass a filter that
|
|
107
|
+
# happens to be called "key" in the params hash.
|
|
108
|
+
def each(path, params = {}, key: nil, **filters, &block)
|
|
109
|
+
return enum_for(:each, path, params, key: key, **filters) unless block_given?
|
|
110
|
+
|
|
111
|
+
each_record(path, key || response_key_for(path), params.merge(filters), require_key: true, &block)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A single record by id from any endpoint: fetch("customer", id) is
|
|
115
|
+
# GET customer/{id}. The id often comes from outside (a URL param, a
|
|
116
|
+
# webhook), so anything but a plain identifier raises InvalidRequestError
|
|
117
|
+
# rather than being sent.
|
|
118
|
+
def fetch(path, id)
|
|
119
|
+
id = id.to_s
|
|
120
|
+
raise InvalidRequestError, "Commerce7 record id must be a plain identifier, got #{id.inspect}" unless id.match?(ID_FORMAT)
|
|
121
|
+
|
|
122
|
+
get("#{path}/#{id}")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Any GET, for endpoints that don't fit `each`/`fetch`. Returns the parsed
|
|
126
|
+
# response body. Read-only on purpose: this client has no POST/PUT/DELETE.
|
|
127
|
+
def get(path, params = {})
|
|
128
|
+
path = path.to_s # validate and send the same string
|
|
129
|
+
raise InvalidRequestError, "Commerce7 API path must be relative, like \"customer\" or \"order/123\", got #{path.inspect}" unless path.match?(PATH_FORMAT)
|
|
130
|
+
resource = path.split("/").first
|
|
131
|
+
unless Commerce7.configuration.resource_allowed?(resource)
|
|
132
|
+
raise InvalidRequestError, "Commerce7 resource #{resource.inspect} is not in Commerce7.configuration.allowed_resources (#{Commerce7.configuration.allowed_resources.join(', ')})"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
response = with_rate_limit_retry { connection.get(path, params) }
|
|
136
|
+
handle_response(response)
|
|
80
137
|
end
|
|
81
138
|
|
|
82
139
|
private
|
|
83
140
|
|
|
84
141
|
attr_reader :tenant, :base_url, :sleeper
|
|
85
142
|
|
|
86
|
-
def each_record(path, response_key, params = {})
|
|
143
|
+
def each_record(path, response_key, params = {}, require_key: false)
|
|
87
144
|
page = 1
|
|
88
145
|
|
|
89
146
|
loop do
|
|
90
|
-
|
|
147
|
+
body = get(path, params.merge(page: page, limit: PAGE_SIZE))
|
|
148
|
+
if require_key && !body.key?(response_key)
|
|
149
|
+
raise ApiError, "Commerce7 response for #{path.inspect} has no #{response_key.inspect} key (keys: #{body.keys.join(', ')}); pass key: to Client#each"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
records = body[response_key] || []
|
|
91
153
|
records.each { |record| yield record }
|
|
92
154
|
|
|
93
155
|
break if records.size < PAGE_SIZE
|
|
@@ -96,9 +158,8 @@ module Commerce7
|
|
|
96
158
|
end
|
|
97
159
|
end
|
|
98
160
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
handle_response(response)
|
|
161
|
+
def response_key_for(path)
|
|
162
|
+
path.split("/").last.tr("-", "_").pluralize.camelize(:lower)
|
|
102
163
|
end
|
|
103
164
|
|
|
104
165
|
def with_rate_limit_retry
|
|
@@ -28,6 +28,30 @@ module Commerce7
|
|
|
28
28
|
# land in the same trail as the rest of the app's.
|
|
29
29
|
attr_accessor :audit
|
|
30
30
|
|
|
31
|
+
# The Commerce7 API resources this app may read, e.g.
|
|
32
|
+
# %w[club-membership order customer]: the first segment of an API path
|
|
33
|
+
# ("order" in "order/123"). Commerce7::Client refuses any other resource
|
|
34
|
+
# before sending a request, so an app can only reach what it declares,
|
|
35
|
+
# and this list should match the scopes registered for the app in
|
|
36
|
+
# Commerce7's Developer Center. nil (the default) allows every resource;
|
|
37
|
+
# [] allows none.
|
|
38
|
+
attr_reader :allowed_resources
|
|
39
|
+
|
|
40
|
+
def allowed_resources=(resources)
|
|
41
|
+
@allowed_resources = resources.nil? ? nil : Array(resources).map do |resource|
|
|
42
|
+
resource = resource.to_s
|
|
43
|
+
unless resource.match?(/\A[A-Za-z0-9_-]+\z/)
|
|
44
|
+
raise ArgumentError, "Commerce7 allowed_resources entries are bare resource names like \"order\", got #{resource.inspect}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
resource
|
|
48
|
+
end.freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def resource_allowed?(resource)
|
|
52
|
+
allowed_resources.nil? || allowed_resources.include?(resource.to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
31
55
|
def initialize
|
|
32
56
|
@tenant_class_name = "Tenant"
|
|
33
57
|
@webhook_credentials = -> { raise_unconfigured!(:webhook_credentials) }
|
data/lib/commerce7/version.rb
CHANGED
|
@@ -19,6 +19,12 @@ Commerce7.configure do |c|
|
|
|
19
19
|
# webhook-driven mutations, the post-uninstall purge) land in the same
|
|
20
20
|
# trail as the rest of the app's. See the README's "Audit trail" section.
|
|
21
21
|
c.audit = ->(**kwargs) { AuditEvent.record!(**kwargs) }
|
|
22
|
+
|
|
23
|
+
# The Commerce7 API resources this app reads, e.g. %w[club-membership order].
|
|
24
|
+
# Commerce7::Client refuses anything else. Keep it matching the scopes
|
|
25
|
+
# registered for this app in the Developer Center. Starts empty, so add
|
|
26
|
+
# each resource deliberately as the app starts using it.
|
|
27
|
+
c.allowed_resources = []
|
|
22
28
|
end
|
|
23
29
|
|
|
24
30
|
# Runs once, after a tenant activates (first install or a reinstall) —
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: commerce7-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Roberts
|
|
@@ -15,28 +15,28 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version:
|
|
18
|
+
version: 8.1.4
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version:
|
|
25
|
+
version: 8.1.4
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: faraday
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version:
|
|
32
|
+
version: 2.14.4
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version:
|
|
39
|
+
version: 2.14.4
|
|
40
40
|
description: Activation/deactivation lifecycle, webhook dispatch, App Extension staff-JWT
|
|
41
41
|
auth, the Commerce7 REST client, and the post-uninstall data purge Commerce7's security
|
|
42
42
|
review requires — configured once, reused across every Commerce7 app.
|