commerce7-rails 0.2.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +170 -0
- data/app/controllers/commerce7/activations_controller.rb +32 -0
- data/app/controllers/commerce7/base_controller.rb +59 -0
- data/app/controllers/commerce7/deactivations_controller.rb +23 -0
- data/app/controllers/commerce7/extension_controller.rb +70 -0
- data/app/controllers/commerce7/webhooks_controller.rb +53 -0
- data/app/jobs/commerce7/purge_deactivated_tenants_job.rb +35 -0
- data/app/models/concerns/commerce7/tenant_concern.rb +55 -0
- data/app/services/commerce7/account_client.rb +55 -0
- data/app/services/commerce7/client.rb +143 -0
- data/app/views/commerce7/extension/unauthorized.html.erb +2 -0
- data/lib/commerce7/configuration.rb +49 -0
- data/lib/commerce7/engine.rb +16 -0
- data/lib/commerce7/rails.rb +75 -0
- data/lib/commerce7/version.rb +5 -0
- data/lib/commerce7/webhooks.rb +55 -0
- data/lib/generators/commerce7/install/install_generator.rb +35 -0
- data/lib/generators/commerce7/install/templates/POST_INSTALL.md +17 -0
- data/lib/generators/commerce7/install/templates/commerce7.rb +34 -0
- data/lib/generators/commerce7/install/templates/create_tenants.rb.erb +17 -0
- metadata +92 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d17bdff3aaa4490b2fda29996b277c0947c5dcb6539d4bc02b8d81a63cb50fd0
|
|
4
|
+
data.tar.gz: b16fc3cdc51a00bf3e8563283db63fbaf2bc14d5508831038cb26c155ca3ad6e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 52f6b435f3044adf60b7210a78b1d0b69ccfaa018d126f89432a764875d55a4a370d50c87928a7f7e4024314b3c857a863e6ecc8512ce221c0f281065190ccd6
|
|
7
|
+
data.tar.gz: b7160af777302983bc93d39308bcf63a9f4058c177d5e1ddf5b37be021522fcb69dc5d109cbaf12db86550a45e006c52e18d67c98c8e92cbd18df6e56a4f5b94
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eric Roberts
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# commerce7-rails
|
|
2
|
+
|
|
3
|
+
Rails building blocks for building a [Commerce7](https://www.commerce7.com/) App Store integration: activation/deactivation lifecycle, App Extension staff-JWT auth, safe webhook dispatch, the Commerce7 REST client, and the post-uninstall data purge Commerce7's App Store security review requires.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
gem "commerce7-rails", github: "ERCubed/commerce7-rails", tag: "v0.2.0"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
bundle install
|
|
16
|
+
bin/rails generate commerce7:install
|
|
17
|
+
bin/rails db:migrate
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The generator creates `db/migrate/*_create_tenants.rb` (skip/adapt it if your app already has a `tenants` table) and `config/initializers/commerce7.rb`.
|
|
21
|
+
|
|
22
|
+
## Configure
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# config/initializers/commerce7.rb
|
|
26
|
+
Commerce7.configure do |c|
|
|
27
|
+
c.tenant_class_name = "Tenant"
|
|
28
|
+
|
|
29
|
+
# -> { [username, password] } — the Basic Auth pair you configured in
|
|
30
|
+
# Commerce7's Developer Center for the Install/Uninstall URLs and the
|
|
31
|
+
# app-level Web Hook.
|
|
32
|
+
c.webhook_credentials = -> {
|
|
33
|
+
[
|
|
34
|
+
Rails.application.credentials.dig(:commerce7, :webhook_username),
|
|
35
|
+
Rails.application.credentials.dig(:commerce7, :webhook_password)
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# -> { [app_id, app_secret_key] } — the single app-wide Commerce7 API
|
|
40
|
+
# credential pair (not per-tenant).
|
|
41
|
+
c.app_credentials = -> {
|
|
42
|
+
[
|
|
43
|
+
Rails.application.credentials.dig(:commerce7, :app_id),
|
|
44
|
+
Rails.application.credentials.dig(:commerce7, :app_secret_key)
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# ->(event_type:, success:, **kwargs) { ... } — point this at your own
|
|
49
|
+
# audit-log write path so Commerce7-driven events land in the same trail
|
|
50
|
+
# as the rest of your app's.
|
|
51
|
+
c.audit = ->(**kwargs) { AuditEvent.record!(**kwargs) }
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Your tenant model includes the lifecycle concern and, since Commerce7's activation POST carries the installing staff member's name/email, encrypts it:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
class Tenant < ApplicationRecord
|
|
59
|
+
include Commerce7::TenantConcern
|
|
60
|
+
encrypts :raw_activation_payload
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Your app also needs a top-level `Current`, the standard Rails per-request-state convention — `Commerce7::ExtensionController` and `Commerce7::PurgeDeactivatedTenantsJob` both use it:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
68
|
+
attribute :tenant, :staff_user
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Routes
|
|
73
|
+
|
|
74
|
+
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:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
namespace :commerce7 do
|
|
78
|
+
post "activate", to: "activations#create", as: :activate
|
|
79
|
+
post "deactivate", to: "deactivations#create", as: :deactivate
|
|
80
|
+
post "webhooks", to: "webhooks#create", as: :webhooks
|
|
81
|
+
|
|
82
|
+
# Your own App Extension pages, subclassing Commerce7::ExtensionController:
|
|
83
|
+
get "dashboard", to: "dashboard#show", as: :dashboard
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Webhook dispatch
|
|
88
|
+
|
|
89
|
+
`Commerce7::WebhooksController` handles the parsing, Basic Auth, tenant lookup, and audit write. You register what your app actually does for each `(object, action)` pair Commerce7 might send — anything unregistered is a silent no-op, matching Commerce7's own retry-tolerant expectations:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
Commerce7::Webhooks.on("Club Membership", "Create", "Update") do |tenant, payload, actor|
|
|
93
|
+
SyncJob.perform_later(tenant)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
Commerce7::Webhooks.on("Customer", "Delete") do |tenant, payload, actor|
|
|
97
|
+
Current.tenant = tenant
|
|
98
|
+
ClubMember.find_by(commerce7_customer_id: payload["customerId"])&.destroy
|
|
99
|
+
Current.tenant = nil
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Handlers must be **idempotent by construction** (upsert / find-and-destroy) — Commerce7 doesn't expose a delivery/event id to dedupe a redelivery against.
|
|
104
|
+
|
|
105
|
+
## Activation/deactivation hooks
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# Runs once, after a tenant activates (first install or reinstall) — typically
|
|
109
|
+
# a one-time backfill sync, since Web Hooks only fire on future changes.
|
|
110
|
+
Commerce7.on_activate do |tenant, payload|
|
|
111
|
+
SyncJob.perform_later(tenant)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
Commerce7.on_deactivate do |tenant|
|
|
115
|
+
# optional
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## The 30-day post-uninstall purge
|
|
120
|
+
|
|
121
|
+
Commerce7's security review requires customer data deleted within 30 days of app termination. `Commerce7::TenantConcern` soft-deactivates on uninstall (never hard-deletes, so a reinstall within the window keeps the tenant's data) and exposes a `pending_deletion` scope; `Commerce7::PurgeDeactivatedTenantsJob` hard-deletes anything past that window. Schedule it as a recurring job:
|
|
122
|
+
|
|
123
|
+
```yaml
|
|
124
|
+
# config/recurring.yml (Solid Queue)
|
|
125
|
+
production:
|
|
126
|
+
commerce7_purge_deactivated_tenants:
|
|
127
|
+
class: Commerce7::PurgeDeactivatedTenantsJob
|
|
128
|
+
queue: default
|
|
129
|
+
schedule: every day at 3am
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## The REST client
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
client = Commerce7::Client.new(tenant)
|
|
136
|
+
client.each_club_membership { |membership| ... } # paginates automatically
|
|
137
|
+
client.each_customer { |customer| ... }
|
|
138
|
+
client.each_order { |order| ... }
|
|
139
|
+
client.each_order(orderPaidDate: "gte:2026-01-01") { |order| ... } # params pass through as filters
|
|
140
|
+
client.each_product { |product| ... } # variants + per-location inventory inline
|
|
141
|
+
client.each_inventory_location { |location| ... }
|
|
142
|
+
client.fetch_order(order_id)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Handles pagination, 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
|
+
|
|
147
|
+
`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
|
+
|
|
149
|
+
## Security posture
|
|
150
|
+
|
|
151
|
+
This gem exists so every app built on it starts from a "Yes" on Commerce7's App Store security questionnaire, not a retrofit:
|
|
152
|
+
|
|
153
|
+
- **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
|
+
- **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
|
+
- **PII**: `raw_activation_payload` (the installing staff member's name/email) is your model's column to encrypt — see Install above.
|
|
156
|
+
- **Data deletion**: built in — soft-deactivate on uninstall, hard-delete after 30 days (`Commerce7::PurgeDeactivatedTenantsJob`).
|
|
157
|
+
- **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
|
+
- **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.
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
bundle install
|
|
164
|
+
bundle exec rspec
|
|
165
|
+
bundle exec rubocop
|
|
166
|
+
bundle exec brakeman
|
|
167
|
+
bundle exec bundler-audit --update
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Tests run against a minimal dummy Rails app (via [combustion](https://github.com/pat/combustion)) at `spec/dummy`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commerce7
|
|
4
|
+
# Receives Commerce7's activation POST on app install. Per Commerce7's
|
|
5
|
+
# docs, this sends `tenantId` plus the installer's first name, last name,
|
|
6
|
+
# and email — NOT API credentials. That's expected: the App ID/Secret Key
|
|
7
|
+
# is a single app-wide pair (see Commerce7.configuration.app_credentials),
|
|
8
|
+
# not something issued per tenant.
|
|
9
|
+
class ActivationsController < BaseController
|
|
10
|
+
def create
|
|
11
|
+
tenant = Commerce7.configuration.tenant_class.activate!(
|
|
12
|
+
commerce7_tenant_id: params.require(:tenantId),
|
|
13
|
+
payload: activation_payload
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# See Commerce7.on_activate — typically used to kick off a one-time
|
|
17
|
+
# backfill sync, since Commerce7's Web Hooks only fire on future
|
|
18
|
+
# changes, not a newly (re)installed tenant's pre-existing data.
|
|
19
|
+
Commerce7.run_activate_hooks(tenant, activation_payload)
|
|
20
|
+
|
|
21
|
+
Commerce7.audit!(event_type: "tenant_activated", success: true, commerce7_tenant_id: tenant.commerce7_tenant_id, origin_ip: request.remote_ip)
|
|
22
|
+
|
|
23
|
+
head :ok
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def activation_payload
|
|
29
|
+
params.except(:controller, :action).to_unsafe_h
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commerce7
|
|
4
|
+
# Base for Commerce7 server-to-server POSTs: the app-wide Install/Uninstall
|
|
5
|
+
# URLs (activation, deactivation) and the app-level Web Hooks feature. Not
|
|
6
|
+
# browser requests, so this skips the CSRF check and doesn't inherit a
|
|
7
|
+
# host app's ApplicationController (e.g. its allow_browser restriction).
|
|
8
|
+
#
|
|
9
|
+
# Auth is HTTP Basic in both cases, per Commerce7's docs: Install/Uninstall
|
|
10
|
+
# URLs support an optional username/password configured in their
|
|
11
|
+
# dashboard's "Advanced" section, and a Web Hook registered in the same
|
|
12
|
+
# Developer Center app version supports the same "Advanced Authentication"
|
|
13
|
+
# — both are a single app-wide credential pair set once, not something
|
|
14
|
+
# each tenant configures (see Commerce7.configuration.webhook_credentials).
|
|
15
|
+
class BaseController < ActionController::Base
|
|
16
|
+
# Explicit rather than relying on a host app's `default_protect_from_forgery`
|
|
17
|
+
# config default — this gem shouldn't depend on that being set for its
|
|
18
|
+
# own controllers' safety. Immediately skipped below since these are
|
|
19
|
+
# server-to-server requests with no session/cookie to forge against.
|
|
20
|
+
protect_from_forgery with: :exception
|
|
21
|
+
skip_before_action :verify_authenticity_token, raise: false
|
|
22
|
+
|
|
23
|
+
before_action :authenticate_commerce7!
|
|
24
|
+
|
|
25
|
+
rescue_from ActionController::ParameterMissing do |error|
|
|
26
|
+
render json: { error: error.message }, status: :bad_request
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Deliberately not authenticate_or_request_with_http_basic: that method's
|
|
32
|
+
# return value is truthy even on failure (it's a bare
|
|
33
|
+
# `response_body = message` assignment under the hood — see
|
|
34
|
+
# ActionController::HttpAuthentication::Basic::ProtectedMethods#authentication_request),
|
|
35
|
+
# so it can't be used as a success/failure signal for auditing. Calling
|
|
36
|
+
# authenticate_with_http_basic directly and handling the 401 ourselves
|
|
37
|
+
# gives an unambiguous boolean instead.
|
|
38
|
+
def authenticate_commerce7!
|
|
39
|
+
return if authenticate_with_http_basic { |username, password| valid_commerce7_credentials?(username, password) }
|
|
40
|
+
|
|
41
|
+
Commerce7.audit!(
|
|
42
|
+
event_type: "commerce7_server_auth",
|
|
43
|
+
success: false,
|
|
44
|
+
commerce7_tenant_id: params[:tenantId],
|
|
45
|
+
origin_ip: request.remote_ip,
|
|
46
|
+
metadata: { path: request.path }
|
|
47
|
+
)
|
|
48
|
+
request_http_basic_authentication
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def valid_commerce7_credentials?(username, password)
|
|
52
|
+
expected_username, expected_password = Commerce7.configuration.webhook_credentials.call
|
|
53
|
+
|
|
54
|
+
expected_username.present? && expected_password.present? &&
|
|
55
|
+
ActiveSupport::SecurityUtils.secure_compare(username, expected_username) &&
|
|
56
|
+
ActiveSupport::SecurityUtils.secure_compare(password, expected_password)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commerce7
|
|
4
|
+
# Receives Commerce7's deactivation POST on app uninstall. Soft-deactivates
|
|
5
|
+
# the tenant (never hard-deletes) so a reinstall can reactivate the same
|
|
6
|
+
# record — see Commerce7::TenantConcern#activate! and
|
|
7
|
+
# Commerce7::PurgeDeactivatedTenantsJob, which enforces the actual
|
|
8
|
+
# post-uninstall deletion after a retention window. A tenantId this app
|
|
9
|
+
# doesn't recognize is a no-op, not an error, since webhook/POST retries
|
|
10
|
+
# are common and shouldn't fail loudly.
|
|
11
|
+
class DeactivationsController < BaseController
|
|
12
|
+
def create
|
|
13
|
+
tenant = Commerce7.configuration.tenant_class.find_by(commerce7_tenant_id: params.require(:tenantId))
|
|
14
|
+
if tenant
|
|
15
|
+
tenant.deactivate!
|
|
16
|
+
Commerce7.run_deactivate_hooks(tenant)
|
|
17
|
+
Commerce7.audit!(event_type: "tenant_deactivated", success: true, commerce7_tenant_id: tenant.commerce7_tenant_id, origin_ip: request.remote_ip)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
head :ok
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commerce7
|
|
4
|
+
# Base for pages embedded as a Commerce7 App Extension (iframe). Commerce7
|
|
5
|
+
# appends `tenantId` and `account` (a staff JWT) to the iframe src URL;
|
|
6
|
+
# this validates that JWT against Commerce7's API and resolves
|
|
7
|
+
# Current.tenant/Current.staff_user before any subclass action runs. A
|
|
8
|
+
# host app is expected to define a top-level `Current < ActiveSupport::
|
|
9
|
+
# CurrentAttributes` with `tenant` and `staff_user` attributes — the same
|
|
10
|
+
# convention Rails apps already reach for to scope per-request state.
|
|
11
|
+
class ExtensionController < ActionController::Base
|
|
12
|
+
# Explicit rather than relying on a host app's `default_protect_from_forgery`
|
|
13
|
+
# config default — this gem shouldn't depend on that being set for its
|
|
14
|
+
# own controllers' safety. A subclass that adds a mutating, browser-
|
|
15
|
+
# submitted action inherits this; Commerce7's staff-JWT re-validation on
|
|
16
|
+
# every request (see authenticate_staff! below) is a second, independent
|
|
17
|
+
# boundary a subclass can lean on if it needs to skip this one (e.g. for
|
|
18
|
+
# a cross-site iframe POST where the session cookie may not travel).
|
|
19
|
+
protect_from_forgery with: :exception
|
|
20
|
+
|
|
21
|
+
before_action :authenticate_staff!
|
|
22
|
+
|
|
23
|
+
# Rails sends X-Frame-Options: SAMEORIGIN by default, which blocks
|
|
24
|
+
# Commerce7's admin panel (a different origin) from framing this page at
|
|
25
|
+
# all. Commerce7 doesn't publish a stable admin origin to scope a
|
|
26
|
+
# replacement CSP frame-ancestors to, so this just drops the blanket
|
|
27
|
+
# deny; a host app that wants a tighter CSP can add its own
|
|
28
|
+
# frame-ancestors directive once that origin is confirmed.
|
|
29
|
+
after_action { response.headers.delete("X-Frame-Options") }
|
|
30
|
+
|
|
31
|
+
rescue_from ActionController::ParameterMissing do |error|
|
|
32
|
+
render plain: error.message, status: :bad_request
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def authenticate_staff!
|
|
38
|
+
tenant_id = params.require(:tenantId)
|
|
39
|
+
tenant = Commerce7.configuration.tenant_class.active.find_by(commerce7_tenant_id: tenant_id)
|
|
40
|
+
unless tenant
|
|
41
|
+
audit_auth!(success: false, tenant_id: tenant_id, reason: "unknown_or_deactivated_tenant")
|
|
42
|
+
# Same real error page as a rejected staff token (see below), not a
|
|
43
|
+
# bare status code — an uninstalled-then-still-open tab is the
|
|
44
|
+
# common way staff land here.
|
|
45
|
+
return render "commerce7/extension/unauthorized", status: :forbidden
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Current.staff_user = Commerce7::AccountClient.new.fetch_user(tenant_id: tenant_id, token: params.require(:account))
|
|
49
|
+
Current.tenant = tenant
|
|
50
|
+
audit_auth!(success: true, tenant_id: tenant_id, actor: Current.staff_user["email"])
|
|
51
|
+
rescue Commerce7::AccountClient::AuthenticationError
|
|
52
|
+
audit_auth!(success: false, tenant_id: tenant_id, reason: "invalid_staff_token")
|
|
53
|
+
# A host app can override this view (same path, its own app/views) to
|
|
54
|
+
# match its own styling; this gem ships a plain-text fallback so
|
|
55
|
+
# staff always see a real error page here, never a bare 401.
|
|
56
|
+
render "commerce7/extension/unauthorized", status: :unauthorized
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def audit_auth!(success:, tenant_id:, actor: nil, reason: nil)
|
|
60
|
+
Commerce7.audit!(
|
|
61
|
+
event_type: "staff_extension_auth",
|
|
62
|
+
success: success,
|
|
63
|
+
actor: actor,
|
|
64
|
+
commerce7_tenant_id: tenant_id,
|
|
65
|
+
origin_ip: request.remote_ip,
|
|
66
|
+
metadata: reason ? { reason: reason } : {}
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commerce7
|
|
4
|
+
# Receives Commerce7's Web Hooks — configured once, app-wide, in the
|
|
5
|
+
# Developer Center's app version under "Step 1. APIs & Webhooks", NOT
|
|
6
|
+
# per-tenant. Per Commerce7's docs (developer.commerce7.com/docs/
|
|
7
|
+
# app-apis-webhooks), a webhook registered there applies automatically to
|
|
8
|
+
# every tenant that installs the app, no per-winery setup required — this
|
|
9
|
+
# is a different, app-level mechanism from a store's own independent
|
|
10
|
+
# "Developer > Web Hooks" admin page, which is for a winery's own
|
|
11
|
+
# integrations, unrelated to marketplace apps like this one.
|
|
12
|
+
#
|
|
13
|
+
# Per Commerce7's docs (developer.commerce7.com/docs/webhooks), the body
|
|
14
|
+
# is {object, action, payload, user, tenantId}; object/action cover far
|
|
15
|
+
# more than any one app acts on, so this only parses and dispatches —
|
|
16
|
+
# see Commerce7::Webhooks for how a host app registers what it cares
|
|
17
|
+
# about. Anything with no registered handler is silently a no-op rather
|
|
18
|
+
# than an error.
|
|
19
|
+
class WebhooksController < BaseController
|
|
20
|
+
# `action` is also the name Rails reserves for the controller action
|
|
21
|
+
# itself (routing sets params[:action] = "create" on every request
|
|
22
|
+
# regardless of body content) — reading it via `params` would silently
|
|
23
|
+
# return "create" no matter what Commerce7 actually sent, matching no
|
|
24
|
+
# registered handler and turning every webhook into a silent no-op.
|
|
25
|
+
# Parsing the raw JSON body instead sidesteps that collision entirely.
|
|
26
|
+
def create
|
|
27
|
+
body = JSON.parse(request.body.read)
|
|
28
|
+
return head :bad_request unless body["tenantId"].present? && body["object"].present? && body["action"].present?
|
|
29
|
+
|
|
30
|
+
tenant = Commerce7.configuration.tenant_class.active.find_by(commerce7_tenant_id: body["tenantId"])
|
|
31
|
+
handle(tenant, object: body["object"], action: body["action"], payload: body["payload"] || {}, actor: body["user"]) if tenant
|
|
32
|
+
|
|
33
|
+
head :ok
|
|
34
|
+
rescue JSON::ParserError
|
|
35
|
+
head :bad_request
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def handle(tenant, object:, action:, payload:, actor:)
|
|
41
|
+
handled = Commerce7::Webhooks.dispatch(object: object, action: action, tenant: tenant, payload: payload, actor: actor)
|
|
42
|
+
return unless handled
|
|
43
|
+
|
|
44
|
+
Commerce7.audit!(
|
|
45
|
+
event_type: "webhook_#{object.parameterize(separator: '_')}_#{action.downcase}",
|
|
46
|
+
success: true,
|
|
47
|
+
actor: actor,
|
|
48
|
+
commerce7_tenant_id: tenant.commerce7_tenant_id,
|
|
49
|
+
origin_ip: request.remote_ip
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commerce7
|
|
4
|
+
# Schedule this as a recurring job (e.g. Solid Queue's config/recurring.yml,
|
|
5
|
+
# `schedule: every day at 3am`) to enforce Commerce7's app security policy:
|
|
6
|
+
# customer data deleted within 30 days of app termination. Hard-deletes
|
|
7
|
+
# any tenant still deactivated past Commerce7::TenantConcern::
|
|
8
|
+
# DATA_RETENTION_DAYS — cascading to whatever `dependent: :destroy`
|
|
9
|
+
# associations the host's tenant model declares.
|
|
10
|
+
class PurgeDeactivatedTenantsJob < ::ActiveJob::Base
|
|
11
|
+
queue_as :default
|
|
12
|
+
|
|
13
|
+
def perform
|
|
14
|
+
Commerce7.configuration.tenant_class.pending_deletion.find_each { |tenant| purge(tenant) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# Many tenant-scoped hosts (see the app's TenantScoped-style pattern)
|
|
20
|
+
# resolve their default_scope through Current.tenant, which
|
|
21
|
+
# `dependent: :destroy` associations rely on to find what to cascade —
|
|
22
|
+
# skipping this can leave dependent rows behind, or make the tenant
|
|
23
|
+
# DELETE itself fail outright against a real FK constraint. Setting
|
|
24
|
+
# Current.tenant here mirrors what Commerce7::ExtensionController and
|
|
25
|
+
# Commerce7::WebhooksController already do per-request.
|
|
26
|
+
def purge(tenant)
|
|
27
|
+
commerce7_tenant_id = tenant.commerce7_tenant_id
|
|
28
|
+
Current.tenant = tenant
|
|
29
|
+
tenant.destroy!
|
|
30
|
+
Commerce7.audit!(event_type: "tenant_data_purged", success: true, commerce7_tenant_id: commerce7_tenant_id)
|
|
31
|
+
ensure
|
|
32
|
+
Current.tenant = nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commerce7
|
|
4
|
+
# Include in the host app's own tenant model (see
|
|
5
|
+
# Commerce7.configuration.tenant_class_name) to get the activate/
|
|
6
|
+
# deactivate lifecycle Commerce7::ActivationsController,
|
|
7
|
+
# DeactivationsController, ExtensionController, and WebhooksController all
|
|
8
|
+
# depend on. Expects the model to have commerce7_tenant_id,
|
|
9
|
+
# activated_at, deactivated_at, and raw_activation_payload columns — see
|
|
10
|
+
# Commerce7::Generators::InstallGenerator for a migration that adds them.
|
|
11
|
+
#
|
|
12
|
+
# raw_activation_payload carries PII (the installing staff member's name/
|
|
13
|
+
# email, per Commerce7's activation docs) — add `encrypts
|
|
14
|
+
# :raw_activation_payload` in the including model.
|
|
15
|
+
module TenantConcern
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
# Commerce7's app security policy requires customer data deleted within
|
|
19
|
+
# 30 days of app termination — see Commerce7::PurgeDeactivatedTenantsJob,
|
|
20
|
+
# which hard-deletes any tenant still deactivated after this window.
|
|
21
|
+
# Kept short of 30 days deliberately: a tenant reactivated via
|
|
22
|
+
# .activate! before this elapses keeps all its data.
|
|
23
|
+
DATA_RETENTION_DAYS = 30
|
|
24
|
+
|
|
25
|
+
included do
|
|
26
|
+
validates :commerce7_tenant_id, presence: true, uniqueness: true
|
|
27
|
+
|
|
28
|
+
scope :active, -> { where(deactivated_at: nil) }
|
|
29
|
+
scope :pending_deletion, -> { where.not(deactivated_at: nil).where(deactivated_at: ..DATA_RETENTION_DAYS.days.ago) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class_methods do
|
|
33
|
+
# Handles both first install and a reinstall of a previously
|
|
34
|
+
# deactivated tenant (find_or_initialize_by, not create!) —
|
|
35
|
+
# activation always clears deactivated_at, matching Commerce7
|
|
36
|
+
# sending an activation POST either way.
|
|
37
|
+
def activate!(commerce7_tenant_id:, payload:)
|
|
38
|
+
tenant = find_or_initialize_by(commerce7_tenant_id: commerce7_tenant_id)
|
|
39
|
+
tenant.update!(activated_at: Time.current, deactivated_at: nil, raw_activation_payload: payload)
|
|
40
|
+
tenant
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def active?
|
|
45
|
+
deactivated_at.nil?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Soft-deactivates, never deletes — an uninstall may be followed by a
|
|
49
|
+
# reinstall (see .activate!), and this app's synced data shouldn't
|
|
50
|
+
# vanish just because the app was temporarily removed.
|
|
51
|
+
def deactivate!
|
|
52
|
+
update!(deactivated_at: Time.current)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commerce7
|
|
4
|
+
# Validates the staff JWT Commerce7 passes into an App Extension iframe
|
|
5
|
+
# (the `account` URL param) via GET /account/user. Confirmed against
|
|
6
|
+
# Commerce7's docs: Authorization header carries the raw token (no
|
|
7
|
+
# "Bearer" prefix), `tenant` header carries the tenantId URL param.
|
|
8
|
+
#
|
|
9
|
+
# Distinct from Commerce7::Client: that one authenticates as the app
|
|
10
|
+
# (Basic Auth with App ID/Secret Key) for server-to-server calls; this
|
|
11
|
+
# authenticates as the staff member currently viewing the iframe, and
|
|
12
|
+
# needs no app credentials of its own.
|
|
13
|
+
class AccountClient
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
class AuthenticationError < Error; end
|
|
16
|
+
class ApiError < Error; end
|
|
17
|
+
|
|
18
|
+
BASE_URL = Commerce7::Client::BASE_URL
|
|
19
|
+
|
|
20
|
+
def initialize(base_url: BASE_URL)
|
|
21
|
+
@base_url = base_url
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fetch_user(tenant_id:, token:)
|
|
25
|
+
response = connection.get("account/user") do |req|
|
|
26
|
+
req.headers["Authorization"] = token
|
|
27
|
+
req.headers["tenant"] = tenant_id
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
handle_response(response)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :base_url
|
|
36
|
+
|
|
37
|
+
def handle_response(response)
|
|
38
|
+
case response.status
|
|
39
|
+
when 200..299
|
|
40
|
+
response.body
|
|
41
|
+
when 401
|
|
42
|
+
raise AuthenticationError, "Commerce7 rejected the staff token"
|
|
43
|
+
else
|
|
44
|
+
raise ApiError, "Commerce7 API error (#{response.status}): #{response.body}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def connection
|
|
49
|
+
@connection ||= Faraday.new(url: base_url) do |f|
|
|
50
|
+
f.response :json
|
|
51
|
+
f.adapter Faraday.default_adapter
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commerce7
|
|
4
|
+
# HTTP client for Commerce7's REST API. Confirmed against Commerce7's public
|
|
5
|
+
# docs: base URL, Basic Auth (App ID/App Secret Key as user/pass), endpoint
|
|
6
|
+
# paths, page/limit pagination (max 50/page, matches PAGE_SIZE), response
|
|
7
|
+
# envelope keys, and the 100 req/min rate limit.
|
|
8
|
+
#
|
|
9
|
+
# The App ID/App Secret Key pair is a single pair for the app as a whole,
|
|
10
|
+
# not one per tenant (see Commerce7.configuration.app_credentials) — so the
|
|
11
|
+
# `tenant` header is the only thing that scopes a request to a specific
|
|
12
|
+
# winery's data.
|
|
13
|
+
class Client
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
class AuthenticationError < Error; end
|
|
16
|
+
class RateLimitedError < Error; end
|
|
17
|
+
class ApiError < Error; end
|
|
18
|
+
|
|
19
|
+
# Trailing slash matters: Faraday/URI joins a relative path onto this by
|
|
20
|
+
# RFC 3986 merge rules, so without it "v1" gets treated as a filename and
|
|
21
|
+
# dropped (e.g. base ".../v1" + "customer" => ".../customer", not ".../v1/customer").
|
|
22
|
+
BASE_URL = "https://api.commerce7.com/v1/"
|
|
23
|
+
PAGE_SIZE = 50
|
|
24
|
+
MAX_RETRIES = 3
|
|
25
|
+
|
|
26
|
+
def initialize(tenant, base_url: BASE_URL, sleeper: ->(seconds) { sleep(seconds) })
|
|
27
|
+
@app_id, @app_secret_key = Commerce7.configuration.app_credentials.call
|
|
28
|
+
if @app_id.blank? || @app_secret_key.blank?
|
|
29
|
+
raise ArgumentError, "Commerce7.configuration.app_credentials returned a blank app_id/app_secret_key"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@tenant = tenant
|
|
33
|
+
@base_url = base_url
|
|
34
|
+
@sleeper = sleeper
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def each_customer(&block)
|
|
38
|
+
return enum_for(:each_customer) unless block_given?
|
|
39
|
+
|
|
40
|
+
each_record("customer", "customers", &block)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def each_club_membership(&block)
|
|
44
|
+
return enum_for(:each_club_membership) unless block_given?
|
|
45
|
+
|
|
46
|
+
each_record("club-membership", "clubMemberships", &block)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# `params` pass straight through as Commerce7 query filters (e.g.
|
|
50
|
+
# `orderPaidDate: "gte:2026-01-01"`), so a caller can bound a listing
|
|
51
|
+
# instead of paging through a tenant's entire order history.
|
|
52
|
+
def each_order(params = {}, &block)
|
|
53
|
+
return enum_for(:each_order, params) unless block_given?
|
|
54
|
+
|
|
55
|
+
each_record("order", "orders", params, &block)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Products carry their variants inline, and each variant carries its
|
|
59
|
+
# per-location inventory counts (variants[].inventory[], keyed by
|
|
60
|
+
# inventoryLocationId) plus the winery's custom fields (metaData) —
|
|
61
|
+
# enough for a full inventory snapshot without a separate call per SKU.
|
|
62
|
+
def each_product(params = {}, &block)
|
|
63
|
+
return enum_for(:each_product, params) unless block_given?
|
|
64
|
+
|
|
65
|
+
each_record("product", "products", params, &block)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def each_inventory_location(params = {}, &block)
|
|
69
|
+
return enum_for(:each_inventory_location, params) unless block_given?
|
|
70
|
+
|
|
71
|
+
each_record("inventory-location", "inventoryLocations", params, &block)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Single-order lookup (as opposed to each_order's bulk listing) — some
|
|
75
|
+
# App Extension placements (e.g. an Order Detail tab) only get an
|
|
76
|
+
# orderId from Commerce7. Returns the order hash, which carries a
|
|
77
|
+
# top-level customerId same as club-membership's.
|
|
78
|
+
def fetch_order(order_id)
|
|
79
|
+
get("order/#{order_id}", {})
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
attr_reader :tenant, :base_url, :sleeper
|
|
85
|
+
|
|
86
|
+
def each_record(path, response_key, params = {})
|
|
87
|
+
page = 1
|
|
88
|
+
|
|
89
|
+
loop do
|
|
90
|
+
records = get(path, params.merge(page: page, limit: PAGE_SIZE))[response_key] || []
|
|
91
|
+
records.each { |record| yield record }
|
|
92
|
+
|
|
93
|
+
break if records.size < PAGE_SIZE
|
|
94
|
+
|
|
95
|
+
page += 1
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def get(path, params)
|
|
100
|
+
response = with_rate_limit_retry { connection.get(path, params) }
|
|
101
|
+
handle_response(response)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def with_rate_limit_retry
|
|
105
|
+
attempt = 0
|
|
106
|
+
|
|
107
|
+
loop do
|
|
108
|
+
response = yield
|
|
109
|
+
return response unless response.status == 429
|
|
110
|
+
|
|
111
|
+
attempt += 1
|
|
112
|
+
raise RateLimitedError, "Commerce7 rate limit exceeded after #{MAX_RETRIES} retries" if attempt > MAX_RETRIES
|
|
113
|
+
|
|
114
|
+
sleeper.call(retry_delay(response, attempt))
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def retry_delay(response, attempt)
|
|
119
|
+
retry_after = response.headers["retry-after"].to_s.to_i
|
|
120
|
+
retry_after.positive? ? retry_after : 2**attempt
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def handle_response(response)
|
|
124
|
+
case response.status
|
|
125
|
+
when 200..299
|
|
126
|
+
response.body
|
|
127
|
+
when 401
|
|
128
|
+
raise AuthenticationError, "Commerce7 rejected the tenant's credentials"
|
|
129
|
+
else
|
|
130
|
+
raise ApiError, "Commerce7 API error (#{response.status}): #{response.body}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def connection
|
|
135
|
+
@connection ||= Faraday.new(url: base_url) do |f|
|
|
136
|
+
f.request :authorization, :basic, @app_id, @app_secret_key
|
|
137
|
+
f.headers["tenant"] = tenant.commerce7_tenant_id
|
|
138
|
+
f.response :json
|
|
139
|
+
f.adapter Faraday.default_adapter
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commerce7
|
|
4
|
+
# Everything a host app wires up once, in config/initializers/commerce7.rb
|
|
5
|
+
# (see Commerce7::Generators::InstallGenerator for a starting template).
|
|
6
|
+
# There's no default tenant/webhook/app credential source deliberately —
|
|
7
|
+
# this gem never assumes Rails encrypted credentials over any other
|
|
8
|
+
# secrets store, so a missing config surfaces as a clear error rather than
|
|
9
|
+
# silently falling back to something the host never configured.
|
|
10
|
+
class Configuration
|
|
11
|
+
# The ActiveRecord class that includes Commerce7::TenantConcern.
|
|
12
|
+
attr_accessor :tenant_class_name
|
|
13
|
+
|
|
14
|
+
# -> { [webhook_username, webhook_password] } — HTTP Basic credentials
|
|
15
|
+
# Commerce7 was configured (in its Developer Center) to send on the
|
|
16
|
+
# Install/Uninstall URLs and the app-level Web Hook.
|
|
17
|
+
attr_accessor :webhook_credentials
|
|
18
|
+
|
|
19
|
+
# -> { [app_id, app_secret_key] } — the single app-wide Commerce7 API
|
|
20
|
+
# credential pair (not per-tenant) used by Commerce7::Client.
|
|
21
|
+
attr_accessor :app_credentials
|
|
22
|
+
|
|
23
|
+
# ->(event_type:, success:, **kwargs) { ... } — called for every
|
|
24
|
+
# security-relevant event this gem's controllers/jobs produce (auth
|
|
25
|
+
# success/failure, activation/deactivation, webhook-driven mutation,
|
|
26
|
+
# purge). Point this at your app's own audit-log write path (e.g.
|
|
27
|
+
# `->(**kw) { AuditEvent.record!(**kw) }`) so Commerce7-driven events
|
|
28
|
+
# land in the same trail as the rest of the app's.
|
|
29
|
+
attr_accessor :audit
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@tenant_class_name = "Tenant"
|
|
33
|
+
@webhook_credentials = -> { raise_unconfigured!(:webhook_credentials) }
|
|
34
|
+
@app_credentials = -> { raise_unconfigured!(:app_credentials) }
|
|
35
|
+
@audit = ->(**) { }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def tenant_class
|
|
39
|
+
tenant_class_name.constantize
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def raise_unconfigured!(setting)
|
|
45
|
+
raise Commerce7::Error,
|
|
46
|
+
"Commerce7.configuration.#{setting} is not set — configure it in config/initializers/commerce7.rb"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commerce7
|
|
4
|
+
# Adds this gem's app/{controllers,models/concerns,jobs,services,views}
|
|
5
|
+
# to the host app's autoload/eager-load and view paths — the standard
|
|
6
|
+
# Rails::Engine subclassing convention. Deliberately not isolated
|
|
7
|
+
# (no `isolate_namespace`) and mounts no routes of its own: a host app
|
|
8
|
+
# keeps declaring its own config/routes.rb entries exactly as it would
|
|
9
|
+
# for any in-app controller, just pointing at these gem-provided classes
|
|
10
|
+
# (e.g. `post "activate", to: "commerce7/activations#create"`). That keeps
|
|
11
|
+
# the URLs Commerce7's Developer Center has registered (Install/Uninstall
|
|
12
|
+
# URLs, the App Extension iframe src) stable and host-controlled.
|
|
13
|
+
class Engine < ::Rails::Engine
|
|
14
|
+
engine_name "commerce7"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "commerce7/version"
|
|
5
|
+
require "commerce7/configuration"
|
|
6
|
+
require "commerce7/webhooks"
|
|
7
|
+
require "commerce7/engine" if defined?(Rails::Engine)
|
|
8
|
+
|
|
9
|
+
module Commerce7
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def configuration
|
|
14
|
+
@configuration ||= Configuration.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def configure
|
|
18
|
+
yield configuration
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Runs after Commerce7::ActivationsController activates (or
|
|
22
|
+
# reactivates) a tenant — typically used to kick off a one-time backfill
|
|
23
|
+
# sync, since Commerce7's Web Hooks only fire on future changes, not a
|
|
24
|
+
# newly (re)installed tenant's pre-existing data.
|
|
25
|
+
def on_activate(&block)
|
|
26
|
+
activate_hooks << block
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Runs after Commerce7::DeactivationsController soft-deactivates a
|
|
30
|
+
# tenant. Optional — Commerce7::PurgeDeactivatedTenantsJob independently
|
|
31
|
+
# enforces the 30-day post-uninstall deletion requirement regardless of
|
|
32
|
+
# whether a host registers this hook.
|
|
33
|
+
def on_deactivate(&block)
|
|
34
|
+
deactivate_hooks << block
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run_activate_hooks(tenant, payload)
|
|
38
|
+
activate_hooks.each { |hook| hook.call(tenant, payload) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run_deactivate_hooks(tenant)
|
|
42
|
+
deactivate_hooks.each { |hook| hook.call(tenant) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Auditing must never be why the thing it's observing fails — a bad
|
|
46
|
+
# configured audit hook shouldn't 500 a webhook delivery or a staff
|
|
47
|
+
# member's page load. Logged loudly on failure so a real bug in the
|
|
48
|
+
# host's audit hook still surfaces instead of vanishing.
|
|
49
|
+
def audit!(**kwargs)
|
|
50
|
+
configuration.audit.call(**kwargs)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
Rails.logger.error("Commerce7.audit! hook raised: #{e.class}: #{e.message}") if defined?(Rails)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Test helper: resets configuration, lifecycle hooks, and webhook
|
|
56
|
+
# registrations. Call from a spec suite's global before/after hook so
|
|
57
|
+
# one example's Commerce7.configure doesn't leak into the next.
|
|
58
|
+
def reset!
|
|
59
|
+
@configuration = nil
|
|
60
|
+
@activate_hooks = []
|
|
61
|
+
@deactivate_hooks = []
|
|
62
|
+
Commerce7::Webhooks.reset!
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def activate_hooks
|
|
68
|
+
@activate_hooks ||= []
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def deactivate_hooks
|
|
72
|
+
@deactivate_hooks ||= []
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commerce7
|
|
4
|
+
# Registry for Commerce7::WebhooksController to dispatch into. Commerce7's
|
|
5
|
+
# Web Hooks feature (registered once, app-wide, in the Developer Center's
|
|
6
|
+
# "APIs & Webhooks" step) delivers a body of {object, action, payload,
|
|
7
|
+
# user, tenantId} covering far more object/action pairs than any one app
|
|
8
|
+
# acts on — object/action this app doesn't register a handler for are a
|
|
9
|
+
# silent no-op, not an error, matching Commerce7's own retry-tolerant
|
|
10
|
+
# expectations.
|
|
11
|
+
#
|
|
12
|
+
# Register handlers from a host app initializer:
|
|
13
|
+
#
|
|
14
|
+
# Commerce7::Webhooks.on("Club Membership", "Create", "Update") do |tenant, payload, actor|
|
|
15
|
+
# SyncJob.perform_later(tenant)
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# A handler block receives the tenant (an instance of the configured
|
|
19
|
+
# tenant class), the raw payload hash, and the actor string Commerce7
|
|
20
|
+
# attached to the delivery (its "user" field, if any). Handlers are
|
|
21
|
+
# expected to be idempotent by construction (upsert / find-and-destroy) —
|
|
22
|
+
# Commerce7 doesn't expose a delivery/event id to dedupe against, so a
|
|
23
|
+
# redelivered webhook must land at the same end state, not double-apply.
|
|
24
|
+
module Webhooks
|
|
25
|
+
Handler = Struct.new(:object, :actions, :block)
|
|
26
|
+
private_constant :Handler
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
def on(object, *actions, &block)
|
|
30
|
+
handlers << Handler.new(object, actions, block)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Runs every handler registered for this (object, action) pair.
|
|
34
|
+
# Returns true if at least one handler ran, so the caller knows
|
|
35
|
+
# whether this was a recognized event worth auditing.
|
|
36
|
+
def dispatch(object:, action:, tenant:, payload:, actor:)
|
|
37
|
+
matched = handlers.select { |handler| handler.object == object && handler.actions.include?(action) }
|
|
38
|
+
matched.each { |handler| handler.block.call(tenant, payload, actor) }
|
|
39
|
+
matched.any?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Test helper: clears registrations between specs so one example's
|
|
43
|
+
# Commerce7::Webhooks.on doesn't leak into the next.
|
|
44
|
+
def reset!
|
|
45
|
+
@handlers = []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def handlers
|
|
51
|
+
@handlers ||= []
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Commerce7
|
|
7
|
+
module Generators
|
|
8
|
+
# `rails generate commerce7:install` — scaffolds the migration and
|
|
9
|
+
# initializer a fresh host app needs to start using this gem. Doesn't
|
|
10
|
+
# touch an existing `tenants` table if the host already has one; review
|
|
11
|
+
# and adapt the generated migration in that case instead of running it
|
|
12
|
+
# as-is.
|
|
13
|
+
class InstallGenerator < Rails::Generators::Base
|
|
14
|
+
include Rails::Generators::Migration
|
|
15
|
+
|
|
16
|
+
source_root File.expand_path("templates", __dir__)
|
|
17
|
+
|
|
18
|
+
def self.next_migration_number(dirname)
|
|
19
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_migration_file
|
|
23
|
+
migration_template "create_tenants.rb.erb", "db/migrate/create_tenants.rb"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create_initializer
|
|
27
|
+
template "commerce7.rb", "config/initializers/commerce7.rb"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def show_readme
|
|
31
|
+
readme "POST_INSTALL.md" if behavior == :invoke
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
|
|
2
|
+
==============================================================================
|
|
3
|
+
|
|
4
|
+
commerce7-rails installed. Next steps:
|
|
5
|
+
|
|
6
|
+
1. Run the generated migration: bin/rails db:migrate
|
|
7
|
+
2. Add `include Commerce7::TenantConcern` and
|
|
8
|
+
`encrypts :raw_activation_payload` to your Tenant model.
|
|
9
|
+
3. Fill in config/initializers/commerce7.rb — credentials sources, your
|
|
10
|
+
audit hook, and any on_activate/webhook handlers.
|
|
11
|
+
4. Add routes (see the README's "Routes" section) pointing at
|
|
12
|
+
commerce7/activations, commerce7/deactivations, commerce7/webhooks,
|
|
13
|
+
and any Commerce7::ExtensionController subclasses you write.
|
|
14
|
+
5. Schedule Commerce7::PurgeDeactivatedTenantsJob as a recurring job —
|
|
15
|
+
required for Commerce7's 30-day post-uninstall deletion policy.
|
|
16
|
+
|
|
17
|
+
==============================================================================
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Commerce7.configure do |c|
|
|
2
|
+
c.tenant_class_name = "Tenant"
|
|
3
|
+
|
|
4
|
+
c.webhook_credentials = -> {
|
|
5
|
+
[
|
|
6
|
+
Rails.application.credentials.dig(:commerce7, :webhook_username),
|
|
7
|
+
Rails.application.credentials.dig(:commerce7, :webhook_password)
|
|
8
|
+
]
|
|
9
|
+
}
|
|
10
|
+
c.app_credentials = -> {
|
|
11
|
+
[
|
|
12
|
+
Rails.application.credentials.dig(:commerce7, :app_id),
|
|
13
|
+
Rails.application.credentials.dig(:commerce7, :app_secret_key)
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# Point this at your app's own audit-log write path so Commerce7-driven
|
|
18
|
+
# events (server auth, activation/deactivation, staff extension auth,
|
|
19
|
+
# webhook-driven mutations, the post-uninstall purge) land in the same
|
|
20
|
+
# trail as the rest of the app's. See the README's "Audit trail" section.
|
|
21
|
+
c.audit = ->(**kwargs) { AuditEvent.record!(**kwargs) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Runs once, after a tenant activates (first install or a reinstall) —
|
|
25
|
+
# typically a one-time backfill sync, since Commerce7's Web Hooks only fire
|
|
26
|
+
# on future changes.
|
|
27
|
+
# Commerce7.on_activate do |tenant, payload|
|
|
28
|
+
# end
|
|
29
|
+
|
|
30
|
+
# Register a handler per (object, action) pair your app cares about — see
|
|
31
|
+
# Commerce7::Webhooks for the full contract, including the idempotency
|
|
32
|
+
# expectation.
|
|
33
|
+
# Commerce7::Webhooks.on("Club Membership", "Create", "Update") do |tenant, payload, actor|
|
|
34
|
+
# end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class CreateTenants < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :tenants do |t|
|
|
4
|
+
t.string :commerce7_tenant_id, null: false
|
|
5
|
+
t.datetime :activated_at
|
|
6
|
+
t.datetime :deactivated_at
|
|
7
|
+
# PII (installing staff member's name/email, per Commerce7's
|
|
8
|
+
# activation docs) — add `encrypts :raw_activation_payload` on the
|
|
9
|
+
# Tenant model.
|
|
10
|
+
t.jsonb :raw_activation_payload, default: {}, null: false
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :tenants, :commerce7_tenant_id, unique: true
|
|
16
|
+
end
|
|
17
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: commerce7-rails
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Eric Roberts
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: faraday
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
description: Activation/deactivation lifecycle, webhook dispatch, App Extension staff-JWT
|
|
41
|
+
auth, the Commerce7 REST client, and the post-uninstall data purge Commerce7's security
|
|
42
|
+
review requires — configured once, reused across every Commerce7 app.
|
|
43
|
+
executables: []
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- LICENSE
|
|
48
|
+
- README.md
|
|
49
|
+
- app/controllers/commerce7/activations_controller.rb
|
|
50
|
+
- app/controllers/commerce7/base_controller.rb
|
|
51
|
+
- app/controllers/commerce7/deactivations_controller.rb
|
|
52
|
+
- app/controllers/commerce7/extension_controller.rb
|
|
53
|
+
- app/controllers/commerce7/webhooks_controller.rb
|
|
54
|
+
- app/jobs/commerce7/purge_deactivated_tenants_job.rb
|
|
55
|
+
- app/models/concerns/commerce7/tenant_concern.rb
|
|
56
|
+
- app/services/commerce7/account_client.rb
|
|
57
|
+
- app/services/commerce7/client.rb
|
|
58
|
+
- app/views/commerce7/extension/unauthorized.html.erb
|
|
59
|
+
- lib/commerce7/configuration.rb
|
|
60
|
+
- lib/commerce7/engine.rb
|
|
61
|
+
- lib/commerce7/rails.rb
|
|
62
|
+
- lib/commerce7/version.rb
|
|
63
|
+
- lib/commerce7/webhooks.rb
|
|
64
|
+
- lib/generators/commerce7/install/install_generator.rb
|
|
65
|
+
- lib/generators/commerce7/install/templates/POST_INSTALL.md
|
|
66
|
+
- lib/generators/commerce7/install/templates/commerce7.rb
|
|
67
|
+
- lib/generators/commerce7/install/templates/create_tenants.rb.erb
|
|
68
|
+
homepage: https://github.com/ERCubed/commerce7-rails
|
|
69
|
+
licenses:
|
|
70
|
+
- MIT
|
|
71
|
+
metadata:
|
|
72
|
+
homepage_uri: https://github.com/ERCubed/commerce7-rails
|
|
73
|
+
source_code_uri: https://github.com/ERCubed/commerce7-rails
|
|
74
|
+
rubygems_mfa_required: 'true'
|
|
75
|
+
rdoc_options: []
|
|
76
|
+
require_paths:
|
|
77
|
+
- lib
|
|
78
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '3.2'
|
|
83
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '0'
|
|
88
|
+
requirements: []
|
|
89
|
+
rubygems_version: 3.6.9
|
|
90
|
+
specification_version: 4
|
|
91
|
+
summary: Rails building blocks for Commerce7 App Store integrations
|
|
92
|
+
test_files: []
|