studio-engine 0.5.2 → 0.5.4

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: f351e5f2cf75e05dfc8befdd34cb525310ab4c4415bb084d15c57a1e3713b782
4
- data.tar.gz: f84aad6087299527b4cdb330e644ee707f695b6a70597c753039c8b7af81ee93
3
+ metadata.gz: bf5f392cf1242e7e541db8ceb7d4ad1865ef2f71f3c787e582de4ac51fc6f836
4
+ data.tar.gz: 5498dc5d35fbe69c589ba197f3d6fadacbe2d30e93da8b226f0a7d5309ad5ec5
5
5
  SHA512:
6
- metadata.gz: fc184764831f825771d76f9cc93ae51f0103823c571ad886431beeffeb1ba55c15907004dc1796d6901fdafc560571c02cbde8fdd02141f2629c5cd60071f5a0
7
- data.tar.gz: 20a76451338e9aea84191a1c5f3efb995c46514117e18e80d014bda75524ff50e6288abb88da26b84ce463fd296f601e63580de9bfb729458c31cfaf54fa96dc
6
+ metadata.gz: 3c38ce794988d826bc9f91ad47786f314ea94dfebdfb42164741ffe70379dfb912d3073ac74f61f96a2814157fdcb651293c6d5d3ea9ce9927567d982c153120
7
+ data.tar.gz: 6046671a635cef22c25af3231d3e562d258ab0fa45ab568d2255d2c734c44d3e27950acd49ead0bda282b17075def27a6ba6c8e35df14807b6df4e2575890d3c
data/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ 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.4 (2026-06-14)
10
+
11
+ ### Changed
12
+ - Engine magic links are now scanner-safe: emailed links land on an inert GET confirmation page, and the token is consumed only by the CSRF-protected POST from that page.
13
+ - Added the shared `magic_link_consume_path` route helper for consumer apps using engine-drawn auth routes.
14
+
15
+ ## v0.5.3 (2026-06-14)
16
+
17
+ ### Added
18
+ - **`Studio::Email.deliver`** — shared ActionMailer delivery entry point that uses an app-level `EmailDelivery` when present, the engine's namespaced durable outbox when installed, and raw `deliver_later` as a fallback.
19
+ - **`Studio::EmailDelivery` / `Studio::EmailDeliveryJob`** — namespaced durable delivery rows for apps that want shared audit, retry, and resend recovery without colliding with an existing top-level `EmailDelivery` model.
20
+ - **`studio_email_deliveries` migration template** — installable shared outbox table for new or migrating consumer apps.
21
+
22
+ ### Changed
23
+ - Engine magic-link and passwordless signup controllers now send through `Studio::Email.deliver` instead of calling `deliver_later` directly.
24
+
9
25
  ## v0.5.2 (2026-06-13)
10
26
 
11
27
  ### Added
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.2**; see [`CHANGELOG.md`](./CHANGELOG.md) for the history.
14
+ Then `bundle install`. The current release is **v0.5.4**; 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
 
@@ -62,7 +62,7 @@ Rails.application.routes.draw do
62
62
  end
63
63
  ```
64
64
 
65
- This draws the enabled auth routes (`/login`, `/signup`, `/logout`, magic-link routes, Solana routes), OAuth callbacks, optional SSO routes, `/error_logs`, and `/admin/theme`.
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
67
  ## Overriding Views
68
68
 
@@ -1,7 +1,8 @@
1
1
  # Unified create-or-login email magic link (the passwordless email path).
2
2
  #
3
3
  # POST /magic_link — request a link (email [, return_to])
4
- # GET /magic_link/:token — consume it: log in OR create the account
4
+ # GET /magic_link/:token — "Confirm sign-in" interstitial (does NOT consume)
5
+ # POST /magic_link/:token — consume it: log in OR create the account
5
6
  #
6
7
  # create-or-login: clicking the link IS proof of email ownership, so an email
7
8
  # that collides with a Google/wallet-only account that was never email-verified
@@ -14,6 +15,7 @@
14
15
  # sign_in_existing / sign_up_new building blocks.
15
16
  class MagicLinksController < ApplicationController
16
17
  skip_before_action :require_authentication
18
+ layout false, only: :confirm
17
19
 
18
20
  # Respond uniformly for any well-formed email. Under create-or-login every
19
21
  # address is "valid" (it logs in or signs up), so there is nothing to
@@ -22,7 +24,7 @@ class MagicLinksController < ApplicationController
22
24
  email = params[:email].to_s.strip.downcase
23
25
  if email.match?(URI::MailTo::EMAIL_REGEXP)
24
26
  token = MagicLink.generate(email: email, return_to: safe_path(params[:return_to]))
25
- UserMailer.magic_link(email, token).deliver_later
27
+ Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
26
28
  end
27
29
  respond_to do |format|
28
30
  format.json { render json: { success: true } }
@@ -30,13 +32,22 @@ class MagicLinksController < ApplicationController
30
32
  end
31
33
  end
32
34
 
35
+ # GET /magic_link/:token is deliberately inert. Email link scanners and link
36
+ # preview clients frequently prefetch emailed URLs with GET/HEAD; if GET burned
37
+ # the token, the human's first real click could already be invalid. The page
38
+ # renders a CSRF-protected form that a browser auto-POSTs to #consume.
39
+ def confirm
40
+ # strict-origin strips the token-bearing path from subresource Referer
41
+ # headers while preserving a usable Origin header for Rails' CSRF origin
42
+ # check on the consume POST.
43
+ response.set_header("Referrer-Policy", "strict-origin")
44
+ @token = params[:token]
45
+ end
46
+
47
+ # POST /magic_link/:token is the authoritative consume. This is the only place
48
+ # the single-use token is burned.
33
49
  def consume
34
- # Keep the token out of Referer headers on the consume page's subresource
35
- # loads. Single-use + short TTL is the primary defence; this closes the
36
- # passive-leak gap. NOTE: aggressive email link-scanners (Outlook SafeLinks,
37
- # Mimecast) may pre-fetch the link and burn the single-use token before the
38
- # human clicks — a known magic-link tradeoff; documented for support.
39
- response.set_header("Referrer-Policy", "no-referrer")
50
+ response.set_header("Referrer-Policy", "strict-origin")
40
51
  result = MagicLink.consume(params[:token])
41
52
  user = User.find_by(email: result.email)
42
53
  user ? sign_in_existing(user, result) : sign_up_new(result)
@@ -14,7 +14,7 @@ class RegistrationsController < ApplicationController
14
14
  email = (params.dig(:user, :email) || params[:email]).to_s.strip.downcase
15
15
  if email.match?(URI::MailTo::EMAIL_REGEXP)
16
16
  token = MagicLink.generate(email: email)
17
- UserMailer.magic_link(email, token).deliver_later
17
+ Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
18
18
  end
19
19
  return redirect_to login_path, notice: "Check your inbox — we just emailed you a sign-in link."
20
20
  end
@@ -0,0 +1,9 @@
1
+ module Studio
2
+ class EmailDeliveryJob < (defined?(::ApplicationJob) ? ::ApplicationJob : ActiveJob::Base)
3
+ queue_as :mailers
4
+
5
+ def perform(id)
6
+ Studio::EmailDelivery.find_by(id: id)&.deliver_now!
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,46 @@
1
+ module Studio
2
+ class EmailDelivery < ApplicationRecord
3
+ self.table_name = "studio_email_deliveries"
4
+
5
+ belongs_to :user, optional: true
6
+
7
+ scope :unsent, -> { where(sent: false) }
8
+ scope :recent, -> { order(created_at: :desc) }
9
+
10
+ def self.available?
11
+ connection.data_source_exists?(table_name)
12
+ rescue ActiveRecord::ActiveRecordError, NoMethodError
13
+ false
14
+ end
15
+
16
+ def self.deliver(mailer, action, *args, to:, user: nil, **kwargs)
17
+ record = create!(
18
+ mailer: mailer.to_s,
19
+ action: action.to_s,
20
+ email_key: "#{mailer}##{action}",
21
+ to: to.to_s,
22
+ user: user,
23
+ args: ActiveJob::Arguments.serialize(args),
24
+ kwargs: ActiveJob::Arguments.serialize([kwargs]).first
25
+ )
26
+ Studio::EmailDeliveryJob.perform_later(record.id)
27
+ record
28
+ end
29
+
30
+ def deliver_now!
31
+ return if sent?
32
+
33
+ pos = ActiveJob::Arguments.deserialize(args)
34
+ kw = ActiveJob::Arguments.deserialize([kwargs]).first.symbolize_keys
35
+ mailer.constantize.public_send(action, *pos, **kw).deliver_now
36
+ update!(sent: true, sent_at: Time.current, error: nil)
37
+ rescue StandardError => e
38
+ update(error: e.message.to_s.first(500))
39
+ raise
40
+ end
41
+
42
+ def self.resend_unsent!
43
+ unsent.find_each { |delivery| Studio::EmailDeliveryJob.perform_later(delivery.id) }
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,96 @@
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
+ <%= csrf_meta_tags %>
7
+ <title>Signing you in - <%= Studio.app_name %></title>
8
+ <style>
9
+ @keyframes magic-spin { to { transform: rotate(360deg); } }
10
+
11
+ * { box-sizing: border-box; }
12
+
13
+ body {
14
+ margin: 0;
15
+ min-height: 100vh;
16
+ display: grid;
17
+ place-items: center;
18
+ color: #f8fafc;
19
+ background: <%= Studio.theme_dark %>;
20
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
21
+ }
22
+
23
+ main { width: min(100% - 32px, 420px); text-align: center; }
24
+
25
+ .magic-spinner {
26
+ width: 88px;
27
+ height: 88px;
28
+ margin: 0 auto;
29
+ border-radius: 9999px;
30
+ border: 8px solid rgba(255, 255, 255, 0.14);
31
+ border-top-color: <%= Studio.theme_success %>;
32
+ animation: magic-spin 0.8s linear infinite;
33
+ }
34
+
35
+ .magic-fallback {
36
+ display: none;
37
+ margin-top: 2rem;
38
+ color: rgba(248, 250, 252, 0.8);
39
+ }
40
+
41
+ .magic-fallback p { margin: 0 0 1rem; font-size: 15px; }
42
+
43
+ .magic-submit {
44
+ display: inline-block;
45
+ max-width: 100%;
46
+ padding: 14px 34px;
47
+ border: 0;
48
+ border-radius: 8px;
49
+ color: #ffffff;
50
+ background: <%= Studio.theme_success %>;
51
+ font: inherit;
52
+ font-size: 16px;
53
+ font-weight: 700;
54
+ cursor: pointer;
55
+ }
56
+ </style>
57
+ </head>
58
+ <body>
59
+ <main>
60
+ <div class="magic-spinner" role="status" aria-label="Signing you in"></div>
61
+
62
+ <div id="magic-fallback" class="magic-fallback">
63
+ <p>Taking longer than expected?</p>
64
+ <%= button_to magic_link_consume_path(token: @token),
65
+ method: :post,
66
+ form: { id: "magic-consume-form", data: { turbo: "false" } },
67
+ class: "magic-submit" do %>
68
+ Sign in to <%= Studio.app_name %>
69
+ <% end %>
70
+ </div>
71
+ </main>
72
+
73
+ <noscript>
74
+ <style>
75
+ #magic-fallback { display: block !important; }
76
+ .magic-spinner { display: none; }
77
+ </style>
78
+ </noscript>
79
+
80
+ <script>
81
+ (function () {
82
+ var form = document.getElementById("magic-consume-form");
83
+ if (form && !form.dataset.autoSubmitted) {
84
+ form.dataset.autoSubmitted = "1";
85
+ if (typeof form.requestSubmit === "function") form.requestSubmit();
86
+ else form.submit();
87
+ }
88
+
89
+ setTimeout(function () {
90
+ var fallback = document.getElementById("magic-fallback");
91
+ if (fallback) fallback.style.display = "block";
92
+ }, 4000);
93
+ })();
94
+ </script>
95
+ </body>
96
+ </html>
@@ -0,0 +1,22 @@
1
+ class CreateStudioEmailDeliveries < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :studio_email_deliveries do |t|
4
+ t.string :email_key, null: false
5
+ t.string :to
6
+ t.string :mailer, null: false
7
+ t.string :action, null: false
8
+ t.jsonb :args, null: false, default: []
9
+ t.jsonb :kwargs, null: false, default: {}
10
+ t.boolean :sent, null: false, default: false
11
+ t.datetime :sent_at
12
+ t.text :error
13
+ t.references :user, foreign_key: true
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :studio_email_deliveries, :sent
19
+ add_index :studio_email_deliveries, :email_key
20
+ add_index :studio_email_deliveries, :created_at
21
+ end
22
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ module Email
5
+ class << self
6
+ # Shared email send entry point for Studio apps.
7
+ #
8
+ # Apps with an existing top-level EmailDelivery model keep using it through
9
+ # this facade. Apps that have installed the engine outbox migration use the
10
+ # namespaced Studio::EmailDelivery model. Apps without either still send via
11
+ # ActionMailer's normal deliver_later path.
12
+ def deliver(mailer, action, *args, to:, user: nil, **kwargs)
13
+ if (adapter = app_delivery_adapter)
14
+ return adapter.deliver(mailer, action, *args, to: to, user: user, **kwargs)
15
+ end
16
+
17
+ if (adapter = studio_delivery_adapter)
18
+ return adapter.deliver(mailer, action, *args, to: to, user: user, **kwargs)
19
+ end
20
+
21
+ mailer.public_send(action, *args, **kwargs).deliver_later
22
+ end
23
+
24
+ private
25
+
26
+ def app_delivery_adapter
27
+ return unless Object.const_defined?(:EmailDelivery, false)
28
+
29
+ adapter = Object.const_get(:EmailDelivery, false)
30
+ adapter if adapter.respond_to?(:deliver)
31
+ rescue NameError
32
+ nil
33
+ end
34
+
35
+ def studio_delivery_adapter
36
+ return unless Studio.const_defined?(:EmailDelivery, false)
37
+
38
+ adapter = Studio.const_get(:EmailDelivery, false)
39
+ adapter if adapter.respond_to?(:available?) && adapter.available?
40
+ rescue NameError
41
+ nil
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.5.2"
2
+ VERSION = "0.5.4"
3
3
  end
data/lib/studio.rb CHANGED
@@ -5,6 +5,7 @@ require "studio/theme_resolver"
5
5
  require "studio/username_generator"
6
6
  require "studio/s3"
7
7
  require "studio/image_cache"
8
+ require "studio/email"
8
9
  require "studio/mail_transport"
9
10
 
10
11
  module Studio
@@ -37,7 +38,7 @@ module Studio
37
38
  mattr_accessor :draw_auth_routes, default: true
38
39
 
39
40
  # Default From: for engine-sent mail (magic links). Apps set this to their
40
- # verified Resend sending address in config/initializers/studio.rb.
41
+ # verified sending address in config/initializers/studio.rb.
41
42
  mattr_accessor :mailer_from, default: nil
42
43
 
43
44
  # Theme role colors (7 roles)
@@ -158,12 +159,16 @@ module Studio
158
159
  get "auth/failure", to: "omniauth_callbacks#failure"
159
160
 
160
161
  # Passwordless email (magic link). Helpers: magic_link_request_path (POST
161
- # to request a link) + magic_link_path(token) / magic_link_url(token:)
162
- # (the emailed consume link). The token is a URL-safe MessageVerifier blob
163
- # but the constraint guards against a stray "." segment.
162
+ # to request a link), magic_link_path(token) / magic_link_url(token:)
163
+ # for the emailed GET confirmation page, and magic_link_consume_path(token)
164
+ # for the scanner-safe POST consume. The token is a URL-safe
165
+ # MessageVerifier blob but the constraint guards against a stray "."
166
+ # segment.
164
167
  if Studio.draw_auth_routes && Studio.auth_method?(:magic_link)
165
- post "magic_link", to: "magic_links#create", as: :magic_link_request
166
- get "magic_link/:token", to: "magic_links#consume", as: :magic_link,
168
+ post "magic_link", to: "magic_links#create", as: :magic_link_request
169
+ get "magic_link/:token", to: "magic_links#confirm", as: :magic_link,
170
+ constraints: { token: %r{[^/]+} }
171
+ post "magic_link/:token", to: "magic_links#consume", as: :magic_link_consume,
167
172
  constraints: { token: %r{[^/]+} }
168
173
  end
169
174
 
@@ -18,7 +18,7 @@ Gem::Specification.new do |spec|
18
18
  "changelog_uri" => "https://github.com/amcritchie/studio-engine/blob/main/CHANGELOG.md"
19
19
  }
20
20
 
21
- spec.files = Dir["lib/**/*", "app/**/*", "config/**/*", "tailwind/**/*", "Gemfile", "studio-engine.gemspec", "README.md", "CHANGELOG.md", "LICENSE"]
21
+ spec.files = Dir["lib/**/*", "app/**/*", "config/**/*", "db/**/*", "tailwind/**/*", "Gemfile", "studio-engine.gemspec", "README.md", "CHANGELOG.md", "LICENSE"]
22
22
  spec.require_paths = ["lib"]
23
23
 
24
24
  spec.add_dependency "rails", ">= 7.0", "< 8.0"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 0.5.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -153,6 +153,7 @@ files:
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
156
+ - app/jobs/studio/email_delivery_job.rb
156
157
  - app/mailers/application_mailer.rb
157
158
  - app/mailers/user_mailer.rb
158
159
  - app/models/concerns/sluggable.rb
@@ -160,6 +161,7 @@ files:
160
161
  - app/models/error_log.rb
161
162
  - app/models/image_cache.rb
162
163
  - app/models/session_context.rb
164
+ - app/models/studio/email_delivery.rb
163
165
  - app/models/theme_setting.rb
164
166
  - app/services/google_oauth_validator.rb
165
167
  - app/services/magic_link.rb
@@ -182,6 +184,7 @@ files:
182
184
  - app/views/layouts/_navbar.html.erb
183
185
  - app/views/layouts/studio/_flash.html.erb
184
186
  - app/views/layouts/studio/_head.html.erb
187
+ - app/views/magic_links/confirm.html.erb
185
188
  - app/views/navbar/show.html.erb
186
189
  - app/views/registrations/new.html.erb
187
190
  - app/views/schema/index.html.erb
@@ -199,9 +202,11 @@ files:
199
202
  - app/views/theme_settings/edit.html.erb
200
203
  - app/views/user_mailer/magic_link.html.erb
201
204
  - app/views/user_mailer/magic_link.text.erb
205
+ - db/migrate/20260614000000_create_studio_email_deliveries.rb
202
206
  - lib/studio-engine.rb
203
207
  - lib/studio.rb
204
208
  - lib/studio/color_scale.rb
209
+ - lib/studio/email.rb
205
210
  - lib/studio/engine.rb
206
211
  - lib/studio/image_cache.rb
207
212
  - lib/studio/mail_transport.rb