studio-engine 0.5.4 → 0.5.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bf5f392cf1242e7e541db8ceb7d4ad1865ef2f71f3c787e582de4ac51fc6f836
4
- data.tar.gz: 5498dc5d35fbe69c589ba197f3d6fadacbe2d30e93da8b226f0a7d5309ad5ec5
3
+ metadata.gz: 7dde49831782823962437f0efab2c8334fe0efc4376873ee9b9c4c2486aa4d1f
4
+ data.tar.gz: c9cb4750ad4298869623db8a98e93662b101a1b2ce055fb779d15a3095ba1529
5
5
  SHA512:
6
- metadata.gz: 3c38ce794988d826bc9f91ad47786f314ea94dfebdfb42164741ffe70379dfb912d3073ac74f61f96a2814157fdcb651293c6d5d3ea9ce9927567d982c153120
7
- data.tar.gz: 6046671a635cef22c25af3231d3e562d258ab0fa45ab568d2255d2c734c44d3e27950acd49ead0bda282b17075def27a6ba6c8e35df14807b6df4e2575890d3c
6
+ metadata.gz: bb82ea299130783431b2dfea4fac8ddece0728b7f49d6ea15c1cff49d819fb6687a5a03f4eb80d4a0f8cb590cf78e45ccb1cdec410bc042ba6caed95bdde24b0
7
+ data.tar.gz: e93039393ac8e344f7eef097ce55629f41cdefdb8f48be418ad3f8a7228e1c01363a3646ecd23708cffa182d3e905e17a8c1b2f8eccbfe6b97a3c9ef203a855b
data/CHANGELOG.md CHANGED
@@ -6,6 +6,19 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
6
6
 
7
7
  No entries yet.
8
8
 
9
+ ## v0.5.6 (2026-06-14)
10
+
11
+ ### Added
12
+ - **`Studio.wallet_address_method` / `Studio.user_wallet_address(user)`** —
13
+ shared wallet-address adapter for SSO/session awareness. Defaults support
14
+ `wallet_address` and `solana_address`; apps can configure another method.
15
+
16
+ ## v0.5.5 (2026-06-14)
17
+
18
+ ### Added
19
+ - **Local email inbox** at `/_studio/local_emails` for non-production localhost requests. It lists recent outbox rows and exposes local proof links for magic-link, email-verification, wallet-export, and email-change emails.
20
+ - **`Studio.local_email_capture?`** — shared capture switch for local/worktree stacks. `LOCAL_EMAIL_CAPTURE=1` or `AGENT_WORKTREE=1` records delivery rows without enqueueing external sends.
21
+
9
22
  ## v0.5.4 (2026-06-14)
10
23
 
11
24
  ### Changed
data/README.md CHANGED
@@ -11,7 +11,7 @@ Shared Rails engine for McRitchie apps. Provides authentication, error handling,
11
11
  gem "studio-engine", "~> 0.5"
12
12
  ```
13
13
 
14
- Then `bundle install`. The current release is **v0.5.4**; see [`CHANGELOG.md`](./CHANGELOG.md) for the history.
14
+ Then `bundle install`. The current release is **v0.5.5**; see [`CHANGELOG.md`](./CHANGELOG.md) for the history.
15
15
 
16
16
  > Published to RubyGems as of v0.4.0 (2026-05-17). New installs should use the RubyGems form, which the consumer Rails apps (`mcritchie-studio`, `turf-monster`) already use.
17
17
 
@@ -64,6 +64,8 @@ end
64
64
 
65
65
  This draws the enabled auth routes (`/login`, `/signup`, `/logout`, magic-link request/confirm/consume routes, Solana routes), OAuth callbacks, optional SSO routes, `/error_logs`, and `/admin/theme`. Magic-link emails point at the inert GET confirmation route; the single-use token is consumed only by the CSRF-protected POST to `magic_link_consume_path`.
66
66
 
67
+ In non-production local requests, this also draws `/_studio/local_emails`, a local email inbox for agent/worktree proof flows. Set `LOCAL_EMAIL_CAPTURE=1` or run with `AGENT_WORKTREE=1` to record outbox rows without sending real email.
68
+
67
69
  ## Overriding Views
68
70
 
69
71
  This is a non-isolated engine -- app views at the same path automatically override engine views. For example, placing `app/views/sessions/new.html.erb` in the consuming app replaces the engine's login page.
@@ -49,7 +49,7 @@ module Studio
49
49
  session[:sso_name] = user.try(:name)
50
50
  session[:sso_provider] = user.provider
51
51
  session[:sso_uid] = user.uid
52
- session[:sso_wallet] = user.try(:wallet_address)
52
+ session[:sso_wallet] = Studio.user_wallet_address(user)
53
53
  session[:sso_source] = Studio.app_name
54
54
  session[:sso_logo] = Studio.sso_logo
55
55
  end
@@ -0,0 +1,108 @@
1
+ module Studio
2
+ class LocalEmailsController < ApplicationController
3
+ skip_before_action :require_authentication, raise: false
4
+ layout false
5
+
6
+ before_action :require_local_development!
7
+
8
+ def index
9
+ @deliveries = delivery_records.map { |record| serialize_delivery(record) }
10
+
11
+ respond_to do |format|
12
+ format.html
13
+ format.json do
14
+ render json: {
15
+ capture_enabled: Studio.local_email_capture?,
16
+ inbox_url: request.original_url.sub(/\.json\z/, ""),
17
+ deliveries: @deliveries
18
+ }
19
+ end
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def require_local_development!
26
+ head :not_found if Rails.env.production? || !request.local?
27
+ end
28
+
29
+ def delivery_records
30
+ klass = delivery_class
31
+ return [] unless klass
32
+
33
+ klass.recent.limit(50)
34
+ end
35
+
36
+ def delivery_class
37
+ if Object.const_defined?(:EmailDelivery, false)
38
+ return Object.const_get(:EmailDelivery, false)
39
+ end
40
+
41
+ if Studio.const_defined?(:EmailDelivery, false) && Studio::EmailDelivery.respond_to?(:available?) && Studio::EmailDelivery.available?
42
+ return Studio::EmailDelivery
43
+ end
44
+
45
+ nil
46
+ end
47
+
48
+ def serialize_delivery(record)
49
+ args = deserialize_args(record.args)
50
+ kwargs = deserialize_kwargs(record.kwargs)
51
+ {
52
+ id: record.id,
53
+ email_key: record.email_key,
54
+ mailer: record.mailer,
55
+ action: record.action,
56
+ to: record.to,
57
+ sent: record.sent?,
58
+ sent_at: record.sent_at,
59
+ error: record.error,
60
+ created_at: record.created_at,
61
+ action_url: local_action_url(record, args),
62
+ args_preview: args.map { |arg| preview_value(arg) },
63
+ kwargs_preview: kwargs.transform_values { |value| preview_value(value) }
64
+ }
65
+ end
66
+
67
+ def deserialize_args(value)
68
+ ActiveJob::Arguments.deserialize(value || [])
69
+ rescue StandardError
70
+ []
71
+ end
72
+
73
+ def deserialize_kwargs(value)
74
+ deserialized = ActiveJob::Arguments.deserialize([value || {}]).first
75
+ deserialized.respond_to?(:symbolize_keys) ? deserialized.symbolize_keys : {}
76
+ rescue StandardError
77
+ {}
78
+ end
79
+
80
+ def local_action_url(record, args)
81
+ token = args[1].to_s
82
+ return if token.empty?
83
+
84
+ path =
85
+ case record.email_key
86
+ when /#magic_link\z/
87
+ "/magic_link/#{ERB::Util.url_encode(token)}"
88
+ when /#email_verification\z/
89
+ "/email_verification/#{ERB::Util.url_encode(token)}"
90
+ when /#wallet_export\z/
91
+ "/account/wallet/export/#{ERB::Util.url_encode(token)}"
92
+ when /#email_change_confirmation\z/
93
+ "/account/email/confirm/#{ERB::Util.url_encode(token)}"
94
+ end
95
+
96
+ "#{request.base_url}#{path}" if path
97
+ end
98
+
99
+ def preview_value(value)
100
+ case value
101
+ when String, Numeric, TrueClass, FalseClass, NilClass
102
+ value
103
+ else
104
+ value.respond_to?(:to_global_id) ? value.to_global_id.to_s : value.to_s
105
+ end
106
+ end
107
+ end
108
+ end
@@ -63,6 +63,8 @@ class SessionContext
63
63
 
64
64
  # Primary wallet address (web3 preferred), or nil when logged out / wallet-less.
65
65
  def address
66
+ return Studio.user_wallet_address(user) if defined?(Studio) && Studio.respond_to?(:user_wallet_address)
67
+
66
68
  return nil unless user.respond_to?(:solana_address)
67
69
  user.solana_address
68
70
  end
@@ -23,12 +23,13 @@ module Studio
23
23
  args: ActiveJob::Arguments.serialize(args),
24
24
  kwargs: ActiveJob::Arguments.serialize([kwargs]).first
25
25
  )
26
- Studio::EmailDeliveryJob.perform_later(record.id)
26
+ Studio::EmailDeliveryJob.perform_later(record.id) unless Studio.local_email_capture?
27
27
  record
28
28
  end
29
29
 
30
30
  def deliver_now!
31
31
  return if sent?
32
+ return update!(error: "local email capture enabled; not sent") if Studio.local_email_capture?
32
33
 
33
34
  pos = ActiveJob::Arguments.deserialize(args)
34
35
  kw = ActiveJob::Arguments.deserialize([kwargs]).first.symbolize_keys
@@ -0,0 +1,100 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Local Emails - <%= Studio.app_name %></title>
7
+ <style>
8
+ * { box-sizing: border-box; }
9
+ body {
10
+ margin: 0;
11
+ background: #10101f;
12
+ color: #f8fafc;
13
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
14
+ }
15
+ main { width: min(1180px, calc(100% - 32px)); margin: 32px auto; }
16
+ header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
17
+ h1 { margin: 0 0 8px; font-size: 28px; line-height: 1.2; }
18
+ p { margin: 0; color: #b6bdd3; }
19
+ .pill { display: inline-flex; align-items: center; border: 1px solid #3b3b62; border-radius: 999px; padding: 6px 12px; color: #d8dcf0; background: #18182b; font-size: 13px; white-space: nowrap; }
20
+ .notice { margin-bottom: 18px; border: 1px solid #3b3b62; border-radius: 8px; background: #18182b; padding: 14px 16px; color: #cbd2e8; }
21
+ table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 8px; background: #18182b; }
22
+ th, td { padding: 12px 14px; border-bottom: 1px solid #2f2f4d; text-align: left; vertical-align: top; }
23
+ th { color: #9fa7c3; font-size: 12px; text-transform: uppercase; }
24
+ td { color: #eef2ff; font-size: 14px; }
25
+ tr:last-child td { border-bottom: 0; }
26
+ code { color: #c4b5fd; word-break: break-word; }
27
+ .muted { color: #9fa7c3; }
28
+ .error { color: #fda4af; }
29
+ .button { display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; padding: 8px 12px; background: #4baf50; color: #fff; font-weight: 700; text-decoration: none; white-space: nowrap; }
30
+ .empty { border: 1px solid #3b3b62; border-radius: 8px; background: #18182b; padding: 24px; color: #cbd2e8; }
31
+ @media (max-width: 760px) {
32
+ header { display: block; }
33
+ .pill { margin-top: 14px; }
34
+ table, thead, tbody, tr, th, td { display: block; }
35
+ thead { display: none; }
36
+ tr { border-bottom: 1px solid #2f2f4d; padding: 10px 0; }
37
+ td { border-bottom: 0; padding: 8px 14px; }
38
+ td::before { content: attr(data-label); display: block; color: #9fa7c3; font-size: 12px; text-transform: uppercase; margin-bottom: 4px; }
39
+ }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <main>
44
+ <header>
45
+ <div>
46
+ <h1>Local Emails</h1>
47
+ <p>Recent outbox rows for this local <%= Studio.app_name %> stack.</p>
48
+ </div>
49
+ <span class="pill">Capture <%= Studio.local_email_capture? ? "enabled" : "disabled" %></span>
50
+ </header>
51
+
52
+ <% if Studio.local_email_capture? %>
53
+ <div class="notice">This stack records email intents and does not send them to external providers.</div>
54
+ <% else %>
55
+ <div class="notice">Capture is disabled. This page still shows recent delivery rows, but this stack may send real email when workers run.</div>
56
+ <% end %>
57
+
58
+ <% if @deliveries.empty? %>
59
+ <div class="empty">No email deliveries have been recorded yet.</div>
60
+ <% else %>
61
+ <table>
62
+ <thead>
63
+ <tr>
64
+ <th>When</th>
65
+ <th>Message</th>
66
+ <th>To</th>
67
+ <th>Status</th>
68
+ <th>Proof URL</th>
69
+ </tr>
70
+ </thead>
71
+ <tbody>
72
+ <% @deliveries.each do |delivery| %>
73
+ <tr>
74
+ <td data-label="When"><%= delivery[:created_at]&.strftime("%Y-%m-%d %H:%M:%S") %></td>
75
+ <td data-label="Message"><code><%= delivery[:email_key] %></code></td>
76
+ <td data-label="To"><%= delivery[:to] %></td>
77
+ <td data-label="Status">
78
+ <% if delivery[:sent] %>
79
+ Sent
80
+ <% elsif delivery[:error].present? %>
81
+ <span class="error"><%= delivery[:error] %></span>
82
+ <% else %>
83
+ <span class="muted">Captured</span>
84
+ <% end %>
85
+ </td>
86
+ <td data-label="Proof URL">
87
+ <% if delivery[:action_url].present? %>
88
+ <a class="button" href="<%= delivery[:action_url] %>">Open link</a>
89
+ <% else %>
90
+ <span class="muted">No local action URL</span>
91
+ <% end %>
92
+ </td>
93
+ </tr>
94
+ <% end %>
95
+ </tbody>
96
+ </table>
97
+ <% end %>
98
+ </main>
99
+ </body>
100
+ </html>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.5.4"
2
+ VERSION = "0.5.6"
3
3
  end
data/lib/studio.rb CHANGED
@@ -16,6 +16,7 @@ module Studio
16
16
  mattr_accessor :configure_new_user, default: ->(user) {}
17
17
  mattr_accessor :configure_sso_user, default: ->(user) {}
18
18
  mattr_accessor :sso_logo, default: nil
19
+ mattr_accessor :wallet_address_method, default: nil
19
20
  mattr_accessor :theme_logos, default: []
20
21
 
21
22
  # ---- Authentication ------------------------------------------------------
@@ -41,6 +42,10 @@ module Studio
41
42
  # verified sending address in config/initializers/studio.rb.
42
43
  mattr_accessor :mailer_from, default: nil
43
44
 
45
+ # Local/worktree email capture. nil means "auto": enabled when AGENT_WORKTREE
46
+ # is truthy, otherwise disabled. Production always disables capture.
47
+ mattr_accessor :local_email_capture, default: nil
48
+
44
49
  # Theme role colors (7 roles)
45
50
  mattr_accessor :theme_primary, default: "#8E82FE"
46
51
  mattr_accessor :theme_dark, default: "#1A1535"
@@ -88,6 +93,26 @@ module Studio
88
93
  auth_methods.include?(method.to_sym)
89
94
  end
90
95
 
96
+ def self.local_email_capture?
97
+ return false if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
98
+ return !!local_email_capture unless local_email_capture.nil?
99
+
100
+ env_truthy?(ENV["LOCAL_EMAIL_CAPTURE"]) || env_truthy?(ENV["AGENT_WORKTREE"])
101
+ end
102
+
103
+ def self.user_wallet_address(user)
104
+ return nil unless user
105
+
106
+ [wallet_address_method, :wallet_address, :solana_address].compact.each do |method|
107
+ next unless user.respond_to?(method)
108
+
109
+ value = user.public_send(method)
110
+ return value if value && !(value.respond_to?(:empty?) && value.empty?)
111
+ end
112
+
113
+ nil
114
+ end
115
+
91
116
  # Verifies that the host app's User model satisfies the engine's expected
92
117
  # contract. Raises Studio::UserContractError with a clear pointer to
93
118
  # docs/USER_CONTRACT.md if anything required is missing. Called from
@@ -146,6 +171,10 @@ module Studio
146
171
  entry ? "/#{entry[:file]}" : nil
147
172
  end
148
173
 
174
+ def self.env_truthy?(value)
175
+ %w[1 true yes on].include?(value.to_s.strip.downcase)
176
+ end
177
+
149
178
  def self.routes(router)
150
179
  router.instance_exec do
151
180
  get "login", to: "sessions#new"
@@ -158,6 +187,10 @@ module Studio
158
187
  get "auth/:provider/callback", to: "omniauth_callbacks#create"
159
188
  get "auth/failure", to: "omniauth_callbacks#failure"
160
189
 
190
+ unless defined?(Rails) && Rails.env.production?
191
+ get "_studio/local_emails", to: "studio/local_emails#index", as: :studio_local_emails
192
+ end
193
+
161
194
  # Passwordless email (magic link). Helpers: magic_link_request_path (POST
162
195
  # to request a link), magic_link_path(token) / magic_link_url(token:)
163
196
  # for the emailed GET confirmation page, and magic_link_consume_path(token)
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.4
4
+ version: 0.5.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-06-14 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -150,6 +149,7 @@ files:
150
149
  - app/controllers/schema_controller.rb
151
150
  - app/controllers/sessions_controller.rb
152
151
  - app/controllers/solana_sessions_controller.rb
152
+ - app/controllers/studio/local_emails_controller.rb
153
153
  - app/controllers/theme_settings_controller.rb
154
154
  - app/helpers/studio_theme_helper.rb
155
155
  - app/jobs/error_log_cleanup_job.rb
@@ -191,6 +191,7 @@ files:
191
191
  - app/views/sessions/_sso_continue.html.erb
192
192
  - app/views/sessions/new.html.erb
193
193
  - app/views/studio/_cropper_assets.html.erb
194
+ - app/views/studio/local_emails/index.html.erb
194
195
  - app/views/studio/modals/_crop_photo.html.erb
195
196
  - app/views/studio/modals/_host.html.erb
196
197
  - app/views/studio/modals/_image_upload.html.erb
@@ -225,7 +226,6 @@ metadata:
225
226
  source_code_uri: https://github.com/amcritchie/studio-engine/tree/main
226
227
  bug_tracker_uri: https://github.com/amcritchie/studio-engine/issues
227
228
  changelog_uri: https://github.com/amcritchie/studio-engine/blob/main/CHANGELOG.md
228
- post_install_message:
229
229
  rdoc_options: []
230
230
  require_paths:
231
231
  - lib
@@ -240,8 +240,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
240
240
  - !ruby/object:Gem::Version
241
241
  version: '0'
242
242
  requirements: []
243
- rubygems_version: 3.5.11
244
- signing_key:
243
+ rubygems_version: 4.0.9
245
244
  specification_version: 4
246
245
  summary: Shared Rails engine providing auth, SSO, error logging, theming, and S3-backed
247
246
  image caching