studio-engine 0.18.0 → 0.19.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 202706aa6f71348f59cb10a90dd7c726c199ad6399586f68d9ea5177600dd427
4
- data.tar.gz: 24cb9f251d83853e295a88f8ae4d0ba5bab0e447d967f25afe72d1ecdf25317d
3
+ metadata.gz: f49b9ef9699144b785efbe7d4839261bba26035970a6c411f5cd54d0606ea97c
4
+ data.tar.gz: 3705abb5871fcfaa48b7c55613b0bc430b275c8bf9eb4c1625e43fcf14cfed32
5
5
  SHA512:
6
- metadata.gz: ceccb972024f66226009cace56334f895e60384ad613a7063eceb90191e2c9fc5582b64422f4234cb4a136443a33558e5cda856e55eef76264e8d4b62eff432c
7
- data.tar.gz: 435a7cad0e8c0af35b43439841c9fd1e714ebdec7d0e894f9526cde29be6d3d1437ffd6247ff760dd6766b8a52602fb12f0148a6b1002cfe1d830b16b75155f1
6
+ metadata.gz: 584cbbcdeeb388b1a6b0115a790fb6fa3d1c4ce4c69f42e57f7f0985edb4f6338578db04dcfeb5c60bde65aada69509db87c07151a52aab9f9348739a332cdca
7
+ data.tar.gz: dbbe745f30a632c21d528e336dd323a21224e71d39f97caf6fc43f68f958d1cb78da1a8660d735393847bdfeb67a79d15c1f12c199446f665c0267ff521c19fd
data/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
4
4
 
5
+ ## 0.19.0 — 2026-07-27
6
+
7
+ ### Added
8
+
9
+ - **`GET /_studio/local_review` — the local half of the board's WAITING APPROVAL
10
+ button.** A magic link signs the recipient into the app that MINTED it: the
11
+ token lives in that app's store, and `return_to` is sanitized to a same-origin
12
+ PATH. So a link minted by the production task board could only ever land on
13
+ production — the operator clicked "review this locally" and arrived, signed in,
14
+ on `mcritchie.studio`. The board now redirects here instead, to the local
15
+ server named by the task's `local_url`, which mints in its OWN database and
16
+ lands the operator signed-in on the page under review
17
+ (`?email=<who>&return_to=<path>`).
18
+
19
+ Like the local email inbox it sits beside, this is a developer-desk tool that
20
+ hands out sign-in material without authenticating anyone, so it is bound to the
21
+ same floor: the route is drawn outside production only, and every request is
22
+ re-checked against `Studio.local_tool_enabled?` — production or non-loopback
23
+ gets a bare 404.
24
+
25
+ ### Changed
26
+
27
+ - **`Studio.local_tool_enabled?(request_local:)`** — one spelling of the
28
+ developer-desk floor (not production, loopback only), now shared by
29
+ `Studio::LocalEmailsController` and `Studio::LocalReviewsController` so a tool
30
+ added later cannot quietly ship a weaker gate.
31
+
32
+ - **`Studio::MagicLinkIssuing`** — the mint (`issue_magic_link`) and the URL that
33
+ consumes it (`magic_link_url_for`) are two halves of one decision
34
+ (`Studio.magic_link_store`), extracted from `MagicLinksController` and
35
+ `UserMailer` into a shared concern. Handing out the wrong URL for the store
36
+ yields "invalid or expired" on a link that was valid; they can no longer drift
37
+ apart. No behavior change for either existing caller.
38
+
5
39
  ## 0.18.0 — 2026-07-27
6
40
 
7
41
  ### Added
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # Minting a magic link, and building the URL that consumes it — the two halves
5
+ # of one decision (Studio.magic_link_store), kept in one place so they can
6
+ # never drift apart. A token minted in the :database store is only consumable
7
+ # at /l/<token>; a :signed token only at /magic_link/<token>. Handing out the
8
+ # wrong URL for the store yields an "invalid or expired link" on a link that
9
+ # was valid — the failure mode this concern exists to prevent.
10
+ #
11
+ # Included by every issuer: MagicLinksController (the request-a-link flow),
12
+ # UserMailer (the emailed link), and Studio::LocalReviewsController (the
13
+ # dev-only local-review link). An app that overrides MagicLinksController
14
+ # wholesale brings its own issuing and is unaffected.
15
+ module MagicLinkIssuing
16
+ extend ActiveSupport::Concern
17
+
18
+ private
19
+
20
+ # Mint a token in the configured store. Default :signed keeps the legacy
21
+ # stateless MessageVerifier link; :database mints a Studio::Link row (the
22
+ # short, unified scheme). `return_to` is sanitized by both stores.
23
+ def issue_magic_link(email, return_to)
24
+ if Studio.magic_link_store == :database
25
+ Studio::Link.create_magic_link(email: email, return_to: return_to).token
26
+ else
27
+ MagicLink.generate(email: email, return_to: return_to)
28
+ end
29
+ end
30
+
31
+ # The URL that CONSUMES the token this app mints: the short /l/<token> for
32
+ # the :database scheme, the legacy /magic_link/<token> for :signed (and for
33
+ # a :database app that keeps its own /magic_link route, e.g. turf-monster,
34
+ # whose /l is already its landing-page namespace).
35
+ def magic_link_url_for(token)
36
+ Studio.magic_link_via_l_route? ? link_url(token: token) : magic_link_url(token: token)
37
+ end
38
+ end
39
+ end
@@ -15,6 +15,7 @@
15
15
  # sign_in_existing / sign_up_new building blocks.
16
16
  class MagicLinksController < ApplicationController
17
17
  include Studio::LinkConsumption
18
+ include Studio::MagicLinkIssuing
18
19
 
19
20
  skip_before_action :require_authentication
20
21
  layout false, only: :confirm
@@ -57,18 +58,8 @@ class MagicLinksController < ApplicationController
57
58
  redirect_to login_path, alert: "That sign-in link is invalid or has expired. Request a fresh one below."
58
59
  end
59
60
 
60
- private
61
-
62
- # Mint a token in the configured store. Default :signed keeps the legacy
63
- # stateless MessageVerifier link (URL: /magic_link/<token>); :database mints a
64
- # Studio::Link row (URL: /l/<token>) — the short, unified scheme. The mailer
65
- # builds the matching URL (see UserMailer#magic_link). sign_in_existing /
66
- # sign_up_new / safe_path come from Studio::LinkConsumption.
67
- def issue_magic_link(email, return_to)
68
- if Studio.magic_link_store == :database
69
- Studio::Link.create_magic_link(email: email, return_to: return_to).token
70
- else
71
- MagicLink.generate(email: email, return_to: return_to)
72
- end
73
- end
61
+ # issue_magic_link (mint in the configured store) comes from
62
+ # Studio::MagicLinkIssuing, shared with UserMailer — which builds the URL that
63
+ # consumes it so the mint and its landing URL cannot drift apart.
64
+ # sign_in_existing / sign_up_new / safe_path come from Studio::LinkConsumption.
74
65
  end
@@ -23,7 +23,7 @@ module Studio
23
23
  private
24
24
 
25
25
  def require_local_development!
26
- head :not_found if Rails.env.production? || !request.local?
26
+ head :not_found unless Studio.local_tool_enabled?(request_local: request.local?)
27
27
  end
28
28
 
29
29
  def delivery_records
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # GET /_studio/local_review?email=<who>&return_to=<path> — the LOCAL half of
5
+ # the task board's WAITING APPROVAL button.
6
+ #
7
+ # Why it must live here, on the local app. A magic link signs the recipient
8
+ # into the app that MINTED it: the token is a row (or a signed blob) in that
9
+ # app's own store, and Studio::LinkToken.sanitize_path keeps `return_to` a
10
+ # same-origin PATH. So a link minted by the production board can only ever
11
+ # land on production — no matter what host the path belongs to. The board
12
+ # therefore stops minting and REDIRECTS here, to the local server named by the
13
+ # task's local_url, which mints in its own database and lands the operator
14
+ # signed-in on the page under review.
15
+ #
16
+ # It mints for whatever email it is handed, unauthenticated — so it is bound
17
+ # to the same floor as the local email inbox (Studio::LocalEmailsController):
18
+ # never in production, and only for a loopback request. That is the same
19
+ # exposure the inbox already carries, which hands every captured sign-in link
20
+ # to any local reader. It is a development desk convenience; it is not a
21
+ # sign-in path.
22
+ class LocalReviewsController < ApplicationController
23
+ include Studio::MagicLinkIssuing
24
+
25
+ skip_before_action :require_authentication, raise: false
26
+
27
+ before_action :require_local_development!
28
+
29
+ def show
30
+ email = Studio::LinkToken.normalize_email(params[:email])
31
+ return redirect_to(login_path, alert: MISSING_EMAIL) unless email.match?(URI::MailTo::EMAIL_REGEXP)
32
+
33
+ # return_to is passed through raw: BOTH stores sanitize it to a same-origin
34
+ # path on the way in (Studio::Link.create_magic_link and MagicLink.generate
35
+ # each call Studio::LinkToken.sanitize_path). Re-sanitizing here would be a
36
+ # second spelling of a rule that already has one owner — and a mutation run
37
+ # confirmed it guards nothing the store does not already guard.
38
+ token = issue_magic_link(email, params[:return_to])
39
+ redirect_to magic_link_url_for(token), allow_other_host: false
40
+ end
41
+
42
+ private
43
+
44
+ MISSING_EMAIL = "Add ?email=<your address> to mint a local review link."
45
+
46
+ def require_local_development!
47
+ head :not_found unless Studio.local_tool_enabled?(request_local: request.local?)
48
+ end
49
+ end
50
+ end
@@ -1,4 +1,10 @@
1
1
  class UserMailer < ApplicationMailer
2
+ # magic_link_url_for — the URL that consumes the token, matched to
3
+ # Studio.magic_link_store. Shared with the issuers that MINT the token
4
+ # (MagicLinksController, Studio::LocalReviewsController) so the two halves
5
+ # cannot drift apart.
6
+ include Studio::MagicLinkIssuing
7
+
2
8
  # Branded shell (banner + card) for engine-sent UserMailer emails. An app with
3
9
  # its own UserMailer + branded_mailer layout (e.g. turf-monster) overrides both.
4
10
  layout "branded_mailer"
@@ -18,13 +24,4 @@ class UserMailer < ApplicationMailer
18
24
  @banner_alt = "Your #{@app_name} sign-in link"
19
25
  mail(to: email, subject: "Your #{@app_name} sign-in link")
20
26
  end
21
-
22
- private
23
-
24
- # Match the emailed URL to Studio.magic_link_store: the short /l/<token> for
25
- # the :database scheme, the legacy /magic_link/<token> for :signed. The
26
- # request side (MagicLinksController#issue_magic_link) mints the matching token.
27
- def magic_link_url_for(token)
28
- Studio.magic_link_via_l_route? ? link_url(token: token) : magic_link_url(token: token)
29
- end
30
27
  end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.18.0"
2
+ VERSION = "0.19.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -205,6 +205,18 @@ module Studio
205
205
  magic_link_store == :database && draw_link_routes
206
206
  end
207
207
 
208
+ # The floor every developer-desk tool sits on: the local email inbox
209
+ # (Studio::LocalEmailsController) and the local-review mint
210
+ # (Studio::LocalReviewsController). Both hand out sign-in material without
211
+ # authenticating anyone, so both are OFF in production and OFF for any request
212
+ # that did not come from the loopback interface. One spelling, so a tool added
213
+ # later cannot quietly ship a weaker gate. Pass request.local? in.
214
+ def self.local_tool_enabled?(request_local:)
215
+ return false if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
216
+
217
+ !!request_local
218
+ end
219
+
208
220
  def self.local_email_capture?
209
221
  return false if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
210
222
  return !!local_email_capture unless local_email_capture.nil?
@@ -299,8 +311,12 @@ module Studio
299
311
  get "auth/:provider/callback", to: "omniauth_callbacks#create"
300
312
  get "auth/failure", to: "omniauth_callbacks#failure"
301
313
 
314
+ # Developer-desk tools. Drawn outside production, and each controller
315
+ # re-checks Studio.local_tool_enabled? per request (loopback only) — the
316
+ # route being absent is the outer gate, not the only one.
302
317
  unless defined?(Rails) && Rails.env.production?
303
318
  get "_studio/local_emails", to: "studio/local_emails#index", as: :studio_local_emails
319
+ get "_studio/local_review", to: "studio/local_reviews#show", as: :studio_local_review
304
320
  end
305
321
 
306
322
  # Passwordless email (magic link). Helpers: magic_link_request_path (POST
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.18.0
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-07-27 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
@@ -179,6 +178,7 @@ files:
179
178
  - app/controllers/concerns/studio/error_handling.rb
180
179
  - app/controllers/concerns/studio/impersonation.rb
181
180
  - app/controllers/concerns/studio/link_consumption.rb
181
+ - app/controllers/concerns/studio/magic_link_issuing.rb
182
182
  - app/controllers/error_logs_controller.rb
183
183
  - app/controllers/magic_links_controller.rb
184
184
  - app/controllers/navbar_controller.rb
@@ -190,6 +190,7 @@ files:
190
190
  - app/controllers/studio/email_images_controller.rb
191
191
  - app/controllers/studio/links_controller.rb
192
192
  - app/controllers/studio/local_emails_controller.rb
193
+ - app/controllers/studio/local_reviews_controller.rb
193
194
  - app/controllers/studio/models_controller.rb
194
195
  - app/controllers/style_controller.rb
195
196
  - app/controllers/theme_settings_controller.rb
@@ -325,7 +326,6 @@ metadata:
325
326
  source_code_uri: https://github.com/amcritchie/studio-engine/tree/main
326
327
  bug_tracker_uri: https://github.com/amcritchie/studio-engine/issues
327
328
  changelog_uri: https://github.com/amcritchie/studio-engine/blob/main/CHANGELOG.md
328
- post_install_message:
329
329
  rdoc_options: []
330
330
  require_paths:
331
331
  - lib
@@ -340,8 +340,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
340
340
  - !ruby/object:Gem::Version
341
341
  version: '0'
342
342
  requirements: []
343
- rubygems_version: 3.5.22
344
- signing_key:
343
+ rubygems_version: 4.0.9
345
344
  specification_version: 4
346
345
  summary: Shared Rails engine providing auth, SSO, error logging, theming, and S3-backed
347
346
  image caching