studio-engine 0.30.0 → 0.31.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: be8862879ffbd78bccc84e617beb1bae213e57c76c91d421b1b64d8e9f5122ff
4
- data.tar.gz: 8e8fbc3e9f4db83b65f4693aec67f58f183363e873869a89513c62215422345c
3
+ metadata.gz: c36ecfe3e213e5621f205743728461c24d02912ed7e7bbf5109e0c15afd3225e
4
+ data.tar.gz: d92755cdb2ed3f7b8dca941bb514e2c725314056afbc83ee7fb190a4cc4a993c
5
5
  SHA512:
6
- metadata.gz: ae3fee306fed9857d3421c005ac4006f46dda013d95009a0cde4daf6fc010a6c2d604e8ea173d7806985807e8b5603c57e6cef392a2b2fd00943d3db050d645d
7
- data.tar.gz: b629a74f76f684338f82e028e6a5833dd15289720da55fea4f25e08afa1549454bed48df1131f1338a8ac28880e9e86b9201765ab4c7aac043510eefe7a70797
6
+ metadata.gz: 17c9f349695792402a9cbcb245159ebcba107b355edcdc9c5ae3b3d89d23303d0bd3a4bd96846d1d23c23417fbff47a0f73e0db887bd8880faeb4687e7e48c12
7
+ data.tar.gz: c65f117c47ced22be63a3747f0cd2b283f00a80c40802947a1003ada976749242ebd8340a511d89c9806d9449c6a3525eb1cbebb5fdd47d37cf334465d2e2644
data/CHANGELOG.md CHANGED
@@ -2,6 +2,160 @@
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.31.0 — 2026-08-08
6
+
7
+ **One magic-link token format, one door, and a click that stops breaking your
8
+ session.** Two operator-reported faults, one root: the engine shipped two
9
+ magic-link stores side by side, and the legacy one could not support the
10
+ behavior the other needed.
11
+
12
+ The links themselves were the visible half — a `:signed` app mailed a
13
+ ~350-character `MessageVerifier` blob at `/magic_link/<token>`, wrapping four
14
+ lines of an email. The invisible half was worse: **clicking a link a second time
15
+ dumped a signed-in visitor on the login page.** Their cookie survived, but the
16
+ destination said otherwise, and the destination is what people believe. Every
17
+ dead token — used, expired, or unrecognized — funnelled into the same
18
+ `redirect_to login_path, alert:` no matter who was holding a session.
19
+
20
+ Fixing the second required retiring the first. An EXPIRED `MessageVerifier`
21
+ token cannot be decoded, so a `:signed` app could not tell whose dead link it
22
+ was holding — and "is this the visitor's own link?" is the question the whole
23
+ new behavior turns on. A `Studio::Link` row keeps the email past expiry.
24
+
25
+ ### Added
26
+
27
+ - **`Studio::LinkResolution`** — the click decision table as one pure,
28
+ dependency-free module (`lib/studio/link_resolution.rb`), so every cell is
29
+ unit-testable without a controller or a database. Three inputs (did this
30
+ caller burn the link, whose email it carries, who is signed in) resolve to one
31
+ of three actions:
32
+
33
+ | | nobody signed in | the link's own user | somebody else |
34
+ |------------------|------------------|---------------------|------------------|
35
+ | **live** (burned)| `:authenticate` | `:continue` | `:authenticate` |
36
+ | **used/expired** | `:dead` → login | `:dead` → return_to | `:dead` → home |
37
+ | **unknown token**| `:dead` → login | — | `:dead` → home |
38
+
39
+ The invariant running through it: **a dead link never touches the session.**
40
+ - **`:continue`** — a second click on your own still-live link burns the token
41
+ (so a forwarded email stays unusable) and then does nothing else. It
42
+ deliberately does NOT re-authenticate: a host that rotates the session on
43
+ sign-in would otherwise charge a re-click every scrap of session state the
44
+ visitor had built up. From their side it is indistinguishable from following a
45
+ plain link, which is the point.
46
+ - **Dead-link notices name the address and the reason** ("That sign-in link for
47
+ x@y.com has expired.") and, when a session is open, say so plainly ("You are
48
+ still signed in as a@b.com") instead of implying a logout that never happened.
49
+ - **`Studio::Link#burn`** — the non-raising sibling of `#consume!`, returning
50
+ whether THIS caller won the atomic single-use race, plus `#dead_status`
51
+ (`:used` / `:expired`) for the message. Winning the burn IS the proof the link
52
+ was live; a prior `live?` read is not.
53
+ - **`Studio::Link::MissingTable`** — a named error, pointing at the migration to
54
+ copy, in place of a bare `PG::UndefinedTable`. Every consumer pins the engine
55
+ `~> 0.x`, which admits any release below 1.0, so an app that never installed
56
+ the table picks up the row store on its next `bundle update` with nobody
57
+ having adopted anything deliberately. Its boot stays clean (nothing touches
58
+ the table until someone signs in), so without this the failure lands as an
59
+ unreadable adapter error on a real person's sign-in. Guarded by
60
+ `table_exists?`, not a message match, so it holds on any adapter and never
61
+ swallows an unrelated statement failure.
62
+ - **`Studio::LinkToken::TOKEN_LENGTH` / `TOKEN_LENGTH_BOUNDS` / `TOKEN_FORMAT`** —
63
+ the house standard, now asserted rather than described: every token is exactly
64
+ 16 URL-safe characters, inside a 10-20 character bound.
65
+ - **`Studio::LinkConsumption`** gains the whole flow (`preview_magic_link` for
66
+ the inert GET, `consume_magic_link` for the burning POST) plus overridable
67
+ hooks: `link_continue`, `link_dead`, `link_login_path`, `link_home_path`.
68
+ Apps customize by overriding a hook, never by re-deciding.
69
+ - **Two new suites**: `test/lib/studio/link_resolution_test.rb` (the table, cell
70
+ by cell, with the dead-link invariant swept across all nine cells) and
71
+ `test/integration/magic_link_flow_test.rb` (the same flow through a real HTTP
72
+ round trip against a real database — token burn, session cookie, scanner
73
+ prefetch, account switch, open-redirect refusal, and the burn race).
74
+
75
+ ### Changed
76
+
77
+ - **`Studio.magic_link_store` now reads `:database` and nothing else.** Assigning
78
+ `:signed` **raises at boot** with the migration to install, rather than
79
+ silently downgrading — an app that booted anyway would mint rows against a
80
+ table it never migrated and 500 on a real person's sign-in.
81
+ - **`Studio.magic_link_via_l_route?`** follows `draw_link_routes` alone.
82
+ - **`RegistrationsController`** mints through `Studio::MagicLinkIssuing` like
83
+ every other issuer, instead of calling the store directly.
84
+
85
+ ### Removed
86
+
87
+ - **`MagicLink`** (`app/services/magic_link.rb`), the stateless MessageVerifier
88
+ service, and its jti-in-Rails.cache replay guard.
89
+ - **`GET`/`POST /magic_link/:token`** and `MagicLinksController#confirm` /
90
+ `#consume`. `POST /magic_link` (request a link) stays. The token-bearing door
91
+ is `/l/<token>`, and only that.
92
+ - **`app/views/magic_links/confirm.html.erb`** — the `/l` interstitial
93
+ (`studio/links/confirm`) renders the same shared body.
94
+ - **`Studio.magic_link_token_name`** is vestigial: the accessor remains so an
95
+ un-updated initializer still boots, but nothing reads it. Delete the line.
96
+
97
+ ### Consumer migration (required)
98
+
99
+ **Until step 2 lands, the app's magic-link sign-in is broken** — no
100
+ `studio_links` table means the first mint raises `Studio::Link::MissingTable`.
101
+ Do these two in this order; they do not commute.
102
+
103
+ 1. Delete `config.magic_link_store` and `config.magic_link_token_name` from
104
+ `config/initializers/studio.rb`. **First**, because a leftover `= :signed`
105
+ raises while the initializer loads — and `Studio.configure` yields during
106
+ boot, so no rake task (including the migration install below) can run until
107
+ the line is gone.
108
+ 2. Install the `studio_links` table with `bin/rails
109
+ studio_engine:install:migrations && bin/rails db:migrate` — install ALL of
110
+ them, per `docs/NEW_APP_SETUP.md` § 5. Do **not** hand-copy the migration:
111
+ the task installs it as `<timestamp>_create_studio_links.studio_engine.rb`, a
112
+ hand copy keeps its own name, and both declare `class CreateStudioLinks`, so
113
+ `db:migrate` dies on `ActiveRecord::DuplicateMigrationNameError`.
114
+ 3. Replace any `MagicLink.generate` / `MagicLink.consume` call (including in
115
+ test helpers) with `Studio::Link.create_magic_link` / `Studio::Link#burn`.
116
+ 4. An app overriding the link controllers gets the new behavior by calling
117
+ `consume_magic_link` / `preview_magic_link` and overriding hooks. In
118
+ particular, move any `reset_session` into `sign_in_existing` only — the
119
+ `:continue` path must not reach it.
120
+ ## 0.30.1 — 2026-08-08
121
+
122
+ **`NEW_APP_SETUP.md` § 5 gave a command that does not exist.** 0.30.0 documented
123
+ the engine migration install as `bin/rails studio:install:migrations`; the real
124
+ task is **`studio_engine:install:migrations`**, and the copied files land with a
125
+ `.studio_engine.rb` suffix, not `.studio.rb`. The wrong spelling was inferred
126
+ from a consumer file that had been hand-copied rather than generated, and it
127
+ fails loudly (`Unrecognized command`) for anyone who follows the guide.
128
+
129
+ **And one of the copied migrations could fail the whole run.** The task copies
130
+ FOUR *reference* migrations, and `allow_null_image_cache_owner` runs
131
+ `change_column_null :image_caches` — which raised on any app without that table,
132
+ taking the entire `db:migrate` down with it. moms-app hit exactly that.
133
+
134
+ The obvious answer — "review what was copied and delete what doesn't apply" — is
135
+ wrong, and this release does NOT tell you to do it. `install:migrations` builds
136
+ its skip-list from the files **present**, so a deleted copy comes back with a
137
+ fresh timestamp on your next upgrade and fails again. The guard therefore lives
138
+ in the migration, and § 5 now says the simple thing: **install all of them.**
139
+
140
+ ### Fixed
141
+
142
+ - **`allow_null_image_cache_owner` no longer fails `db:migrate` on an app without
143
+ an `image_caches` table.** It ALTERS an app-owned table the engine cannot assume
144
+ exists, and unguarded it raised and took the whole migration run down with it.
145
+ It now no-ops when the table is absent, and still relaxes the owner columns when
146
+ it is present — both halves pinned by
147
+ `test/integration/image_cache_migration_guard_test.rb`.
148
+
149
+ Deleting the copied migration was never a workaround: `install:migrations`
150
+ builds its skip-list from the files **present**, so a deleted copy is re-copied
151
+ with a fresh timestamp on the next upgrade and fails again. Verified by
152
+ re-running the task against a real consumer.
153
+ - `NEW_APP_SETUP.md` § 5: correct task name, correct file suffix, and — now that
154
+ the migration guards itself — the simple instruction to install ALL of them and
155
+ re-run after each upgrade.
156
+ - `EMAIL_TRANSPORT.md`: was pointing at `railties:install:migrations`, which
157
+ copies the migrations of EVERY railtie in the bundle. Scoped to `studio_engine:`.
158
+
5
159
  ## 0.30.0 — 2026-08-08
6
160
 
7
161
  **The hub's link sidebar becomes the engine's out-of-the-box navigation.** New
@@ -94,7 +248,9 @@ are **unchanged** — their production hard-close still stands, which is also wh
94
248
  - **`NEW_APP_SETUP.md` § 9 no longer ships a hand-rolled banner to copy** — it
95
249
  renders the shared partial, and documents the QA/email reality.
96
250
  - **`NEW_APP_SETUP.md` § 5 now installs the engine migrations FIRST**
97
- (`bin/rails studio:install:migrations`). Omitting them is silent:
251
+ (~~`bin/rails studio:install:migrations`~~ **erratum, 0.30.1:** that command
252
+ does not exist; the correct task is `studio_engine:install:migrations`).
253
+ Omitting them is silent:
98
254
  `Studio::Email.deliver` records mail only when `studio_email_deliveries` exists
99
255
  and otherwise falls back to a plain `deliver_later`, so the app drops every
100
256
  captured email and shows an empty inbox. Exactly the mcritchie-industries bug,
data/README.md CHANGED
@@ -36,7 +36,6 @@ Studio.configure do |config|
36
36
  config.welcome_message = ->(user) { "Welcome, #{user.display_name}!" }
37
37
  config.auth_methods = %i[magic_link google]
38
38
  config.registration_params = [:name, :email]
39
- config.magic_link_token_name = "magic_link_my_app_v1"
40
39
  config.mailer_from = Studio.mailer_from_for_transport(
41
40
  ses_from: "My App <team@example.com>"
42
41
  )
@@ -76,7 +75,9 @@ Rails.application.routes.draw do
76
75
  end
77
76
  ```
78
77
 
79
- 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`.
78
+ This draws the enabled auth routes (`/login`, `/signup`, `/logout`, `POST /magic_link` to request a link, `GET`/`POST /l/:token` for the link itself, Solana routes), OAuth callbacks, optional SSO routes, `/error_logs`, and `/admin/theme`. Magic-link emails point at the inert `GET /l/:token` confirmation page; the single-use token is burned only by the CSRF-protected `POST` to `link_consume_path`.
79
+
80
+ **Magic links need the `studio_links` table.** Install it with `bin/rails studio_engine:install:migrations && bin/rails db:migrate` (install all of them) before enabling `:magic_link` — never by hand-copying the migration, which collides with the task's own copy on `class CreateStudioLinks`. Without the table, the first sign-in raises `Studio::Link::MissingTable`.
80
81
 
81
82
  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.
82
83
 
@@ -189,13 +189,18 @@ module Studio
189
189
  raise e
190
190
  rescue StandardError => e
191
191
  error_log = create_error_log(e)
192
+ # `slug` is NOT in the engine's User contract (REQUIRED_USER_INSTANCE_METHODS
193
+ # is admin? + display_name), and `target` is routinely a User. Unguarded,
194
+ # the LOGGER raises NoMethodError and MASKS the original exception — the
195
+ # exact inverse of what this method exists to do. The name is a convenience
196
+ # column; its absence must never cost the record.
192
197
  if target
193
198
  error_log.target = target
194
- error_log.target_name = target.slug
199
+ error_log.target_name = target.slug if target.respond_to?(:slug)
195
200
  end
196
201
  if parent
197
202
  error_log.parent = parent
198
- error_log.parent_name = parent.slug
203
+ error_log.parent_name = parent.slug if parent.respond_to?(:slug)
199
204
  end
200
205
  error_log.save!
201
206
  @_error_logged = true
@@ -1,9 +1,28 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Studio
2
- # Shared create-or-login building blocks for the magic-link / link consume
3
- # controllers (MagicLinksController + Studio::LinksController). `result` is
4
- # anything responding to #email and #return_to — the legacy MagicLink::Result
5
- # OR a Studio::Link — so both token schemes reuse this. Apps that override the
6
- # link controllers (e.g. turf-monster's contest landing) include it too.
4
+ # The magic-link click, start to finish shared by Studio::LinksController
5
+ # and by any app that draws its own token route (turf-monster's contest
6
+ # landing). Two entry points bracket the single-use burn:
7
+ #
8
+ # preview_magic_link(link) the GET. NEVER burns. Returns :live for a link
9
+ # that is still good (the caller renders the scanner-safe interstitial),
10
+ # and otherwise settles the click here and returns :handled.
11
+ # consume_magic_link(link) — the POST. The one and only place a token burns.
12
+ #
13
+ # Both route their answer through Studio::LinkResolution, the pure decision
14
+ # table, so "what should this click do" has exactly one owner and the GET and
15
+ # the POST can never disagree about it. The invariant that table enforces:
16
+ # **a dead link never touches the session.**
17
+ #
18
+ # Apps customize by overriding the hooks at the bottom, not by re-deciding:
19
+ # sign_in_existing / sign_up_new (the authenticate path), link_continue (the
20
+ # viewer's own live link), link_dead (no session mutation, ever), plus
21
+ # link_login_path / link_home_path for apps whose sign-in page is not
22
+ # `login_path`.
23
+ #
24
+ # `link` is anything responding to #email, #return_to, #live?, #burn and
25
+ # #dead_status — in practice a Studio::Link, or nil for an unknown token.
7
26
  #
8
27
  # Relies on the host ApplicationController contract from Studio::ErrorHandling:
9
28
  # set_app_session, rescue_and_log, current_user, plus root_path / login_path.
@@ -12,12 +31,62 @@ module Studio
12
31
 
13
32
  private
14
33
 
34
+ # --- entry points ---------------------------------------------------------
35
+
36
+ # GET. Inert by construction: email scanners, link-preview fetchers and the
37
+ # Gmail image proxy all issue a GET against an emailed URL, so burning here
38
+ # would spend the token before the human ever clicked. A dead link is
39
+ # settled immediately rather than sent through a spinner that only POSTs to
40
+ # learn the same thing.
41
+ def preview_magic_link(link)
42
+ return :live if link&.live?
43
+
44
+ link_dead(resolve_link_click(link, status: dead_status_for(link)), link)
45
+ :handled
46
+ end
47
+
48
+ # POST. Burns first — the burn is atomic, so winning it IS the proof the
49
+ # link was live — then acts on what the burn returned.
50
+ def consume_magic_link(link)
51
+ claimed = link.present? && link.burn
52
+ outcome = resolve_link_click(link, status: claimed ? :claimed : dead_status_for(link))
53
+
54
+ case outcome.action
55
+ when :authenticate
56
+ user = User.find_by(email: link.email)
57
+ user ? sign_in_existing(user, link) : sign_up_new(link)
58
+ when :continue
59
+ link_continue(link, outcome)
60
+ else
61
+ link_dead(outcome, link)
62
+ end
63
+ end
64
+
65
+ def resolve_link_click(link, status:)
66
+ Studio::LinkResolution.call(
67
+ status: status,
68
+ link_email: link&.email,
69
+ session_email: current_user&.email
70
+ )
71
+ end
72
+
73
+ def dead_status_for(link)
74
+ link.nil? ? :unknown : link.dead_status
75
+ end
76
+
77
+ # --- outcomes -------------------------------------------------------------
78
+
15
79
  def sign_in_existing(user, result)
16
80
  set_app_session(user)
17
81
  # Clicking the link proves email ownership, so verify any account that
18
82
  # reached here without it (e.g. a Google/wallet-only signup).
19
- user.update!(email_verified_at: Time.current) if user.respond_to?(:email_verified_at) && user.email_verified_at.blank?
20
- redirect_to(safe_path(result.return_to) || root_path, notice: "Signed in. Welcome back!")
83
+ #
84
+ # rescue_and_log because this is a WRITE, and the session is already
85
+ # established above it: a consumer app with an extra User validation would
86
+ # otherwise turn a routine sign-in into a 500 the visitor sees while
87
+ # actually signed in, with nothing in ErrorLog to explain it.
88
+ rescue_and_log(target: user) { verify_email_ownership(user) }
89
+ redirect_to(link_destination(:return_to, result), notice: "Signed in. Welcome back!")
21
90
  end
22
91
 
23
92
  # Build → configure_new_user → save!. No password — email auth is link-only.
@@ -26,9 +95,9 @@ module Studio
26
95
  Studio.configure_new_user.call(user)
27
96
  rescue_and_log(target: user) do
28
97
  user.save!
29
- user.update!(email_verified_at: Time.current) if user.respond_to?(:email_verified_at)
98
+ verify_email_ownership(user)
30
99
  set_app_session(user)
31
- redirect_to(safe_path(result.return_to) || root_path, notice: Studio.welcome_message.call(user))
100
+ redirect_to(link_destination(:return_to, result), notice: Studio.welcome_message.call(user))
32
101
  end
33
102
  rescue ActiveRecord::RecordNotUnique
34
103
  # Two valid tokens for the same brand-new email consumed near-simultaneously
@@ -37,10 +106,61 @@ module Studio
37
106
  existing = User.find_by(email: result.email)
38
107
  return sign_in_existing(existing, result) if existing
39
108
 
40
- redirect_to login_path, alert: "We couldn't finish creating your account. Please try again."
109
+ redirect_to link_login_path, alert: "We couldn't finish creating your account. Please try again."
41
110
  rescue StandardError => e
42
111
  Rails.logger.error("[Studio::LinkConsumption#sign_up_new] signup failed #{e.class}: #{e.message}")
43
- redirect_to login_path, alert: "We couldn't finish creating your account. Please try again."
112
+ redirect_to link_login_path, alert: "We couldn't finish creating your account. Please try again."
113
+ end
114
+
115
+ # The viewer's own still-live link. The token burns (nobody replays it
116
+ # later) but the session is left exactly as it stands — re-authenticating
117
+ # would buy nothing and would cost a host that rotates the session on
118
+ # sign-in every scrap of state the visitor had built up. From the visitor's
119
+ # side this is indistinguishable from following a plain link, which is the
120
+ # entire point.
121
+ def link_continue(result, _outcome)
122
+ # Same write, same guard as sign_in_existing — and here the stakes are
123
+ # sharper still: this path exists to be invisible, so an unlogged 500 on a
124
+ # re-click would be the loudest thing about it.
125
+ rescue_and_log(target: current_user) { verify_email_ownership(current_user) }
126
+ redirect_to link_destination(:return_to, result)
127
+ end
128
+
129
+ # A used, expired, or unrecognized link. Touching the session here is the
130
+ # bug this whole concern was written to remove: the visitor's session has
131
+ # nothing to do with the state of a token someone mailed them.
132
+ def link_dead(outcome, result)
133
+ path = link_destination(outcome.destination, result)
134
+ return redirect_to(path) if outcome.silent?
135
+
136
+ redirect_to path, outcome.level => outcome.message
137
+ end
138
+
139
+ # --- hooks + helpers ------------------------------------------------------
140
+
141
+ def link_destination(destination, result)
142
+ case destination
143
+ when :login then link_login_path
144
+ when :home then link_home_path
145
+ else safe_path(result&.return_to) || link_home_path
146
+ end
147
+ end
148
+
149
+ # Where a visitor with NO session lands when the link is dead. Apps whose
150
+ # sign-in page is not `login_path` (turf-monster: signin_path) override this.
151
+ def link_login_path
152
+ login_path
153
+ end
154
+
155
+ # Where a click lands when the link's own destination is not ours to follow.
156
+ def link_home_path
157
+ root_path
158
+ end
159
+
160
+ def verify_email_ownership(user)
161
+ return unless user.respond_to?(:email_verified_at) && user.email_verified_at.blank?
162
+
163
+ user.update!(email_verified_at: Time.current)
44
164
  end
45
165
 
46
166
  # Only same-origin absolute paths survive; everything else collapses to nil.
@@ -2,36 +2,31 @@
2
2
 
3
3
  module Studio
4
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.
5
+ # of one decision, kept in one place so they can never drift apart. Handing
6
+ # out the wrong URL for a token yields an "invalid or expired link" on a link
7
+ # that was perfectly valid: the failure mode this concern exists to prevent.
8
+ #
9
+ # Since 0.31.0 there is one store (a Studio::Link row) and one token format
10
+ # (Studio::LinkToken.generate — 16 URL-safe characters), so the only remaining
11
+ # question is the PATH: the standard /l/<token>, or an app's own token route
12
+ # (turf-monster keeps /magic_link/<token> because /l is already its
13
+ # landing-page namespace).
10
14
  #
11
15
  # 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.
16
+ # RegistrationsController (the passwordless signup POST), UserMailer (the
17
+ # emailed link), and Studio::LocalReviewsController (the dev-only local-review
18
+ # link). An app that overrides those wholesale brings its own issuing.
15
19
  module MagicLinkIssuing
16
20
  extend ActiveSupport::Concern
17
21
 
18
22
  private
19
23
 
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.
24
+ # Mint a token. `return_to` is sanitized to a same-origin path by the store.
23
25
  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
26
+ Studio::Link.create_magic_link(email: email, return_to: return_to).token
29
27
  end
30
28
 
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).
29
+ # The URL that CONSUMES the token this app mints.
35
30
  def magic_link_url_for(token)
36
31
  Studio.magic_link_via_l_route? ? link_url(token: token) : magic_link_url(token: token)
37
32
  end
@@ -1,24 +1,21 @@
1
- # Unified create-or-login email magic link (the passwordless email path).
1
+ # Requesting a magic link the passwordless email path's front door.
2
2
  #
3
- # POST /magic_link — request a link (email [, return_to])
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
3
+ # POST /magic_link — request a link (email [, return_to])
6
4
  #
7
- # create-or-login: clicking the link IS proof of email ownership, so an email
8
- # that collides with a Google/wallet-only account that was never email-verified
9
- # is safely logged in here and stamped email_verified_at (unlike from_omniauth,
10
- # which refuses that collision precisely because it lacked this proof).
5
+ # That is the whole controller. The token-bearing half lives at /l/<token>
6
+ # (Studio::LinksController): one token format, one place it burns. Before
7
+ # 0.31.0 this class also owned a /magic_link/:token confirm+consume pair for
8
+ # the retired :signed store, which was a second door onto the same lock.
11
9
  #
12
- # This is the engine's GENERIC base. Apps that need richer post-consume routing
13
- # (e.g. turf-monster's contest landing + picks rehydration + entry-tokens upsell)
14
- # OVERRIDE this controller in the app and reuse the MagicLink service + the
15
- # sign_in_existing / sign_up_new building blocks.
10
+ # create-or-login: clicking the emailed link IS proof of email ownership, so an
11
+ # email that collides with a Google/wallet-only account that was never
12
+ # email-verified is safely signed in at consume time and stamped
13
+ # email_verified_at (unlike from_omniauth, which refuses that collision
14
+ # precisely because it lacked this proof).
16
15
  class MagicLinksController < ApplicationController
17
- include Studio::LinkConsumption
18
16
  include Studio::MagicLinkIssuing
19
17
 
20
18
  skip_before_action :require_authentication
21
- layout false, only: :confirm
22
19
 
23
20
  # Respond uniformly for any well-formed email. Under create-or-login every
24
21
  # address is "valid" (it logs in or signs up), so there is nothing to
@@ -26,7 +23,7 @@ class MagicLinksController < ApplicationController
26
23
  def create
27
24
  email = params[:email].to_s.strip.downcase
28
25
  if email.match?(URI::MailTo::EMAIL_REGEXP)
29
- token = issue_magic_link(email, safe_path(params[:return_to]))
26
+ token = issue_magic_link(email, Studio::LinkToken.sanitize_path(params[:return_to]))
30
27
  Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
31
28
  end
32
29
  respond_to do |format|
@@ -35,31 +32,7 @@ class MagicLinksController < ApplicationController
35
32
  end
36
33
  end
37
34
 
38
- # GET /magic_link/:token is deliberately inert. Email link scanners and link
39
- # preview clients frequently prefetch emailed URLs with GET/HEAD; if GET burned
40
- # the token, the human's first real click could already be invalid. The page
41
- # renders a CSRF-protected form that a browser auto-POSTs to #consume.
42
- def confirm
43
- # strict-origin strips the token-bearing path from subresource Referer
44
- # headers while preserving a usable Origin header for Rails' CSRF origin
45
- # check on the consume POST.
46
- response.set_header("Referrer-Policy", "strict-origin")
47
- @token = params[:token]
48
- end
49
-
50
- # POST /magic_link/:token is the authoritative consume. This is the only place
51
- # the single-use token is burned.
52
- def consume
53
- response.set_header("Referrer-Policy", "strict-origin")
54
- result = MagicLink.consume(params[:token])
55
- user = User.find_by(email: result.email)
56
- user ? sign_in_existing(user, result) : sign_up_new(result)
57
- rescue MagicLink::InvalidToken
58
- redirect_to login_path, alert: "That sign-in link is invalid or has expired. Request a fresh one below."
59
- end
60
-
61
- # issue_magic_link (mint in the configured store) comes from
35
+ # issue_magic_link (mint a Studio::Link row) comes from
62
36
  # Studio::MagicLinkIssuing, shared with UserMailer — which builds the URL that
63
37
  # 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.
65
38
  end
@@ -1,4 +1,6 @@
1
1
  class RegistrationsController < ApplicationController
2
+ include Studio::MagicLinkIssuing
3
+
2
4
  skip_before_action :require_authentication
3
5
 
4
6
  def new
@@ -13,7 +15,7 @@ class RegistrationsController < ApplicationController
13
15
  unless Studio.auth_method?(:password)
14
16
  email = (params.dig(:user, :email) || params[:email]).to_s.strip.downcase
15
17
  if email.match?(URI::MailTo::EMAIL_REGEXP)
16
- token = MagicLink.generate(email: email)
18
+ token = issue_magic_link(email, nil)
17
19
  Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
18
20
  end
19
21
  return redirect_to login_path, notice: "Check your inbox — we just emailed you a sign-in link."
@@ -1,6 +1,6 @@
1
1
  module Studio
2
- # The unified short-token link entry point — GET/POST /l/<token>. Dispatches by
3
- # Studio::Link#kind:
2
+ # The short-token link entry point — GET/POST /l/<token>, the only magic-link
3
+ # door the engine draws. Dispatches by Studio::Link#kind:
4
4
  #
5
5
  # magic_link → scanner-safe confirm interstitial (GET, inert) that auto-POSTs
6
6
  # to #consume, the ONLY place the single-use token is burned +
@@ -9,6 +9,10 @@ module Studio
9
9
  # the link's target (or root). Reusable + safe to prefetch, so
10
10
  # GET does the work (no POST step).
11
11
  #
12
+ # Every decision about what a magic-link click DOES lives in
13
+ # Studio::LinkConsumption / Studio::LinkResolution, not here — including the
14
+ # invariant that a dead link leaves the visitor's session untouched.
15
+ #
12
16
  # Namespaced (not top-level Links) because mcritchie-studio already owns a
13
17
  # public /links linktree (top-level LinksController). Apps needing richer
14
18
  # post-consume routing (contest landing, picks rehydration, age-gate) define
@@ -25,30 +29,24 @@ module Studio
25
29
  response.set_header("Referrer-Policy", "strict-origin")
26
30
  @link = Studio::Link.find_by(token: params[:token])
27
31
 
28
- case @link&.kind
29
- when "magic_link"
30
- @token = params[:token]
31
- render :confirm
32
- when "referral"
32
+ if @link&.kind == "referral"
33
33
  capture_referral(@link)
34
- redirect_to(@link.target || root_path)
35
- else
36
- redirect_to login_path, alert: "That link is invalid or has expired. Request a fresh one below."
34
+ return redirect_to(@link.target || root_path)
37
35
  end
36
+
37
+ # A magic link, or a token with no row behind it. preview_magic_link is
38
+ # inert — it never burns — and settles a dead link itself rather than
39
+ # sending the visitor through a spinner that only POSTs to learn the same.
40
+ @token = params[:token]
41
+ render :confirm if preview_magic_link(@link&.kind == "magic_link" ? @link : nil) == :live
38
42
  end
39
43
 
40
44
  # POST /l/:token — authoritative magic-link consume. Only magic_link kinds are
41
- # consumable here; referral links are reusable and handled entirely on GET.
45
+ # consumable here; referral links are reusable and handled entirely on GET,
46
+ # so a referral token scoped out below reads as an unknown token.
42
47
  def consume
43
48
  response.set_header("Referrer-Policy", "strict-origin")
44
- link = Studio::Link.find_by(token: params[:token])
45
- raise Studio::Link::InvalidToken, "not a magic link" unless link&.kind == "magic_link"
46
-
47
- link.consume! # burns the single-use token; raises if already used / expired
48
- user = User.find_by(email: link.email)
49
- user ? sign_in_existing(user, link) : sign_up_new(link)
50
- rescue Studio::Link::InvalidToken
51
- redirect_to login_path, alert: "That sign-in link is invalid or has expired. Request a fresh one below."
49
+ consume_magic_link(Studio::Link.magic_links.find_by(token: params[:token]))
52
50
  end
53
51
 
54
52
  private
@@ -30,10 +30,10 @@ module Studio
30
30
  email = Studio::LinkToken.normalize_email(params[:email])
31
31
  return redirect_to(login_path, alert: MISSING_EMAIL) unless email.match?(URI::MailTo::EMAIL_REGEXP)
32
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
33
+ # return_to is passed through raw: the store sanitizes it to a same-origin
34
+ # path on the way in (Studio::Link.create_magic_link calls
35
+ # Studio::LinkToken.sanitize_path). Re-sanitizing here would be a second
36
+ # spelling of a rule that already has one owner — and a mutation run
37
37
  # confirmed it guards nothing the store does not already guard.
38
38
  token = issue_magic_link(email, params[:return_to])
39
39
  redirect_to magic_link_url_for(token), allow_other_host: false
@@ -1,8 +1,7 @@
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.
2
+ # magic_link_url_for — the URL that consumes the token. Shared with the
3
+ # issuers that MINT it (MagicLinksController, RegistrationsController,
4
+ # Studio::LocalReviewsController) so the two halves cannot drift apart.
6
5
  include Studio::MagicLinkIssuing
7
6
 
8
7
  # Branded shell (banner + card) for engine-sent UserMailer emails. An app with
@@ -10,9 +9,11 @@ class UserMailer < ApplicationMailer
10
9
  layout "branded_mailer"
11
10
 
12
11
  # Passwordless sign-in link. `email` is a raw string (the recipient may not
13
- # have an account yet). Token is a signed MagicLink payload (email + return_to
14
- # + jti, single-use). Clicking the link logs the recipient in or creates their
15
- # account. App-name-aware so the same template serves every Studio app.
12
+ # have an account yet). The token is a Studio::Link row's short token 16
13
+ # URL-safe characters, single-use, expiring and the email + return_to it
14
+ # stands for stay in the row, off the wire. Clicking the link logs the
15
+ # recipient in or creates their account. App-name-aware so the same template
16
+ # serves every Studio app.
16
17
  #
17
18
  # Engine GENERIC base. An app needing richer copy (e.g. turf-monster's
18
19
  # contest-aware variant) defines its own UserMailer, which wins.
@@ -11,13 +11,20 @@ module Studio
11
11
  # discriminator. Replaces the engine's stateless MessageVerifier MagicLink
12
12
  # service for mcritchie-studio so both apps share one short-token scheme.
13
13
  #
14
- # Like Studio::EmailDelivery, the table lives in each consumer app (copy the
15
- # reference migration in db/migrate); this model is shipped by the gem.
14
+ # Like Studio::EmailDelivery, the table lives in each consumer app installed
15
+ # by `bin/rails studio_engine:install:migrations`, never hand-copied (a hand
16
+ # copy collides with the task's own copy on `class CreateStudioLinks`). This
17
+ # model is shipped by the gem.
16
18
  class Link < ApplicationRecord
17
19
  self.table_name = "studio_links"
18
20
 
19
21
  class InvalidToken < StandardError; end
20
22
 
23
+ # The app enabled :magic_link but never installed the table. Raised in place
24
+ # of a bare PG::UndefinedTable so the first person to hit it reads the fix
25
+ # instead of an adapter error — see mint!.
26
+ class MissingTable < StandardError; end
27
+
21
28
  belongs_to :linkable, polymorphic: true, optional: true
22
29
 
23
30
  validates :kind, inclusion: { in: Studio::LinkToken::KINDS }
@@ -76,6 +83,16 @@ module Studio
76
83
 
77
84
  # create! with a fresh random token, retrying the (astronomically rare)
78
85
  # unique-index collision a couple of times before surfacing the error.
86
+ #
87
+ # The MissingTable rescue exists because every consumer pins the engine as
88
+ # `~> 0.x`, which admits any release below 1.0 — so an app that never
89
+ # installed this table picks up the row store on its next `bundle update`
90
+ # whether or not anyone adopted it deliberately. Its boot is fine (nothing
91
+ # touches the table until someone signs in), and the failure then lands as
92
+ # a bare PG::UndefinedTable on a real person's sign-in. Naming the fix
93
+ # there is the difference between a five-minute repair and an outage
94
+ # nobody can read. The check is `table_exists?`, not a message match, so
95
+ # it holds on any adapter and never swallows an unrelated failure.
79
96
  def mint!(attrs)
80
97
  3.times do
81
98
  return create!(attrs.merge(token: Studio::LinkToken.generate))
@@ -83,6 +100,14 @@ module Studio
83
100
  next
84
101
  end
85
102
  create!(attrs.merge(token: Studio::LinkToken.generate))
103
+ rescue ActiveRecord::StatementInvalid
104
+ raise if table_exists?
105
+
106
+ raise MissingTable,
107
+ "studio-engine magic links need the studio_links table, and #{Studio.app_name} has no " \
108
+ "such table. Run `bin/rails studio_engine:install:migrations && bin/rails db:migrate` " \
109
+ "(install ALL of them). Do not hand-copy the migration — it collides with the task's " \
110
+ "own copy on `class CreateStudioLinks`."
86
111
  end
87
112
  end
88
113
 
@@ -106,6 +131,34 @@ module Studio
106
131
  self
107
132
  end
108
133
 
134
+ # The non-raising sibling of #consume!, and the one the click flow uses.
135
+ # Returns whether THIS caller won the burn — false for a link that was
136
+ # already used, has expired, or lost the atomic race to a concurrent click.
137
+ #
138
+ # Why a boolean and not the exception: "the link was dead" is not an error
139
+ # here, it is one of the two normal outcomes, and the branch it feeds
140
+ # (Studio::LinkResolution) needs the answer as data. Reloads on a loss so
141
+ # the caller reads the row's settled state (consumed_at / expires_at) rather
142
+ # than the copy it held before the race.
143
+ def burn
144
+ consume!
145
+ true
146
+ rescue InvalidToken
147
+ reload
148
+ false
149
+ end
150
+
151
+ # How a failed burn should be described. Only meaningful once #burn has
152
+ # returned false (or on a link that was never burned at all).
153
+ def dead_status
154
+ return :used if single_use? && consumed?
155
+ return :expired if expired?
156
+
157
+ # Neither flag is set but the burn did not land: a concurrent click won
158
+ # it between our read and our write. Same story for the reader.
159
+ :used
160
+ end
161
+
109
162
  def single_use?
110
163
  Studio::LinkToken.single_use?(kind)
111
164
  end
@@ -1,9 +1,10 @@
1
1
  <%#
2
2
  Shared scanner-safe sign-in interstitial. The GET that renders this is inert;
3
3
  the page auto-POSTs the CSRF-protected form to `consume_path` (the only place
4
- a single-use token is burned). Used by both MagicLinksController#confirm
5
- (consume_path: magic_link_consume_path) and Studio::LinksController#show
6
- (consume_path: link_consume_path). Rendered with layout false (full document).
4
+ a single-use token is burned). Rendered by Studio::LinksController#show
5
+ (consume_path: link_consume_path) the engine's only token door since 0.31.0
6
+ and by any app drawing its own token route. Rendered with layout false
7
+ (full document).
7
8
 
8
9
  Local: consume_path — the POST target that burns the token + signs in.
9
10
  %>
@@ -3,6 +3,16 @@
3
3
  # migration; each consumer app installs its own copy (the table is app-owned).
4
4
  class AllowNullImageCacheOwner < ActiveRecord::Migration[7.2]
5
5
  def change
6
+ # No-op on an app that doesn't use ImageCache. `image_caches` is app-owned,
7
+ # so an app can install the engine's migrations without having that table —
8
+ # and unguarded, this raised and failed the whole `db:migrate`.
9
+ #
10
+ # Deleting the copy is NOT a workaround: install:migrations builds its
11
+ # skip-list from the files PRESENT, so a deleted copy is re-copied with a
12
+ # fresh timestamp on the next upgrade and fails again. The guard has to live
13
+ # here, in the migration, or it doesn't hold.
14
+ return unless table_exists?(:image_caches)
15
+
6
16
  change_column_null :image_caches, :owner_type, true
7
17
  change_column_null :image_caches, :owner_id, true
8
18
  end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "link_token"
4
+
5
+ module Studio
6
+ # What a magic-link click should DO — the whole decision as one pure table,
7
+ # free of ActiveRecord and of the controller, so every cell is unit-testable.
8
+ #
9
+ # A click has three inputs: whether this caller BURNED the link (won the
10
+ # single-use race), whose email the link carries, and who is already signed
11
+ # in. Six cells fall out of that, and one invariant runs through them:
12
+ #
13
+ # **A dead link never touches the session.**
14
+ #
15
+ # Before this table, every dead token — used, expired, or unknown — ended at
16
+ # `redirect_to login_path, alert: "invalid or has expired"`, whatever session
17
+ # the visitor was holding. So clicking your own link a second time dumped you
18
+ # on a sign-in page, which reads as being logged out. It never was: the cookie
19
+ # survived, but the destination said otherwise. Now a dead link is at worst a
20
+ # notice, and at best silent.
21
+ #
22
+ # The table (rows = link state, columns = who is signed in):
23
+ #
24
+ # | nobody | the link's own user | somebody else
25
+ # -------------+-----------------+---------------------+------------------
26
+ # live (burned)| :authenticate | :continue | :authenticate
27
+ # used/expired | :dead → login | :dead → return_to | :dead → home
28
+ # unknown token| :dead → login | — | :dead → home
29
+ #
30
+ # `:continue` is the cell the operator asked for by name: a second click on
31
+ # your own still-live link must be "no material difference — just a redirect".
32
+ # It burns the token (so nobody replays it later) and deliberately does NOT
33
+ # re-authenticate, because a host that rotates the session on sign-in — as
34
+ # turf-monster does — would otherwise charge a re-click the price of every
35
+ # scrap of session state the visitor had built up.
36
+ module LinkResolution
37
+ # How the click found the link.
38
+ # :claimed — this caller burned a live link (the only status that authenticates)
39
+ # :used — the row exists and was already consumed, or lost the burn race
40
+ # :expired — the row exists and is past expires_at
41
+ # :unknown — no row for this token, or the token is not a magic link
42
+ STATUSES = %i[claimed used expired unknown].freeze
43
+
44
+ # :authenticate — establish a session for the link's email (sign in or sign up)
45
+ # :continue — the viewer already IS the link's user: keep the session as
46
+ # it stands and land them on the link's destination
47
+ # :dead — do not touch the session at all
48
+ ACTIONS = %i[authenticate continue dead].freeze
49
+
50
+ # Where the click lands.
51
+ # :return_to — the link's own destination (falling back to home)
52
+ # :home — the app root; used when the link belongs to someone else,
53
+ # so its destination is not ours to follow
54
+ # :login — the sign-in page; only ever for a visitor with no session
55
+ DESTINATIONS = %i[return_to home login].freeze
56
+
57
+ Outcome = Struct.new(:action, :destination, :message, :level, keyword_init: true) do
58
+ def authenticate?
59
+ action == :authenticate
60
+ end
61
+
62
+ def continue?
63
+ action == :continue
64
+ end
65
+
66
+ def dead?
67
+ action == :dead
68
+ end
69
+
70
+ # A silent outcome shows the visitor nothing — the "no material
71
+ # difference" re-click.
72
+ def silent?
73
+ message.nil?
74
+ end
75
+ end
76
+
77
+ module_function
78
+
79
+ # @param status [Symbol] one of STATUSES
80
+ # @param link_email [String, nil] the email the link signs in (nil = unknown token)
81
+ # @param session_email [String, nil] the currently signed-in user's email
82
+ # @return [Outcome]
83
+ def call(status:, link_email: nil, session_email: nil)
84
+ raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
85
+
86
+ own = own_link?(link_email, session_email)
87
+
88
+ if status == :claimed
89
+ return Outcome.new(action: :continue, destination: :return_to) if own
90
+
91
+ # Nobody signed in, or somebody else signed in: both establish a session
92
+ # for the link's email. The second case is the deliberate account switch
93
+ # — a live link is proof of ownership, so it outranks the open session.
94
+ return Outcome.new(action: :authenticate, destination: :return_to)
95
+ end
96
+
97
+ # Dead from here down: no branch below may write to the session.
98
+ return Outcome.new(action: :dead, destination: :return_to) if own
99
+
100
+ if Studio::LinkToken.normalize_email(session_email).empty?
101
+ Outcome.new(action: :dead, destination: :login, level: :alert,
102
+ message: dead_message(status: status, link_email: link_email))
103
+ else
104
+ Outcome.new(action: :dead, destination: :home, level: :notice,
105
+ message: dead_message(status: status, link_email: link_email,
106
+ session_email: session_email))
107
+ end
108
+ end
109
+
110
+ # Same email, both sides present. A blank on either side is never a match —
111
+ # an unknown token (no email) must not read as "your own link" just because
112
+ # nobody is signed in.
113
+ def own_link?(link_email, session_email)
114
+ link = Studio::LinkToken.normalize_email(link_email)
115
+ seat = Studio::LinkToken.normalize_email(session_email)
116
+ !link.empty? && link == seat
117
+ end
118
+
119
+ # The notice a dead link earns. It names the address the link was for and
120
+ # why it failed — the detail that turns "something went wrong" into a
121
+ # decision the reader can act on — and, when a session is open, says so
122
+ # plainly, because the whole point is that nothing was lost.
123
+ def dead_message(status:, link_email: nil, session_email: nil)
124
+ addressee = Studio::LinkToken.normalize_email(link_email)
125
+ who = addressee.empty? ? "" : " for #{addressee}"
126
+ why = case status
127
+ when :expired then "has expired"
128
+ when :used then "was already used"
129
+ else "is no longer valid"
130
+ end
131
+
132
+ base = "That sign-in link#{who} #{why}."
133
+ seat = Studio::LinkToken.normalize_email(session_email)
134
+ return "#{base} Request a fresh one below." if seat.empty?
135
+
136
+ "#{base} You are still signed in as #{seat} — request a fresh link to switch accounts."
137
+ end
138
+ end
139
+ end
@@ -18,10 +18,19 @@ module Studio
18
18
  # they are deliberately NOT single-use.
19
19
  SINGLE_USE_KINDS = %w[magic_link].freeze
20
20
 
21
- # 96 bits of entropy~16 URL-safe chars (e.g. "PP-PDbEj5V3-aNh4"). Short
22
- # enough to keep the URL clean, far too large to brute-force — especially
23
- # for single-use, expiring magic links. Matches turf-monster's proven format.
24
- TOKEN_BYTES = 12
21
+ # THE HOUSE TOKEN STANDARD: 12 random bytes exactly 16 URL-safe
22
+ # characters (e.g. "PP-PDbEj5V3-aNh4"). 96 bits of entropy short enough
23
+ # that the whole link fits on one line of an email, far too large to
24
+ # brute-force, especially for a single-use token that expires in minutes.
25
+ #
26
+ # 16 sits mid-range in the house bound of 10-20 characters (TOKEN_LENGTH_
27
+ # BOUNDS), which is the number a reader should sanity-check a link against.
28
+ # urlsafe_base64 emits 4 characters per 3 bytes with no padding, so the
29
+ # length is exact, not approximate — every token is the same width.
30
+ TOKEN_BYTES = 12
31
+ TOKEN_LENGTH = 16
32
+ TOKEN_LENGTH_BOUNDS = (10..20).freeze
33
+ TOKEN_FORMAT = /\A[A-Za-z0-9_-]+\z/
25
34
 
26
35
  module_function
27
36
 
@@ -48,7 +48,8 @@ module Studio
48
48
  "--color-cta-hover" => ColorScale.darken(primary, 0.30),
49
49
  "--color-success" => colors[:success] || "#4BAF50",
50
50
  "--color-warning" => colors[:warning] || "#FF7C47",
51
- "--color-danger" => colors[:danger] || "#EF4444"
51
+ "--color-danger" => colors[:danger] || "#EF4444",
52
+ "--color-accent" => colors[:accent] || "#F72585"
52
53
  }
53
54
  end
54
55
 
@@ -92,7 +93,8 @@ module Studio
92
93
  "--color-cta-hover" => ColorScale.darken(primary, 0.30),
93
94
  "--color-success" => colors[:success] || "#4BAF50",
94
95
  "--color-warning" => colors[:warning] || "#FF7C47",
95
- "--color-danger" => colors[:danger] || "#EF4444"
96
+ "--color-danger" => colors[:danger] || "#EF4444",
97
+ "--color-accent" => colors[:accent] || "#F72585"
96
98
  }
97
99
  end
98
100
  end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.30.0"
2
+ VERSION = "0.31.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -9,6 +9,7 @@ require "studio/username_generator"
9
9
  require "studio/s3"
10
10
  require "studio/image_cache"
11
11
  require "studio/link_token"
12
+ require "studio/link_resolution"
12
13
  require "studio/email"
13
14
  require "studio/email_smoke"
14
15
  require "studio/mail_transport"
@@ -80,18 +81,40 @@ module Studio
80
81
  # end
81
82
  mattr_accessor :sidebar_sections, default: []
82
83
 
83
- # Magic-link (passwordless email) tuning. token_name keys the MessageVerifier
84
- # purpose; bump it to invalidate every outstanding link. See MagicLink service.
85
- mattr_accessor :magic_link_ttl, default: 15.minutes
84
+ # How long a freshly minted magic link stays live.
85
+ mattr_accessor :magic_link_ttl, default: 15.minutes
86
+
87
+ # RETIRED (0.31.0) — kept only so an initializer that still sets it boots.
88
+ # It named the MessageVerifier purpose for the old :signed store, which no
89
+ # longer exists. Delete the line from your initializer.
86
90
  mattr_accessor :magic_link_token_name, default: "magic_link_v1"
87
91
 
88
- # Where magic-link tokens are stored / which URL scheme they use.
89
- # :signed (default) stateless MessageVerifier MagicLink service; URL is
90
- # /magic_link/<long token>. No table needed. Back-compat default.
91
- # :database a Studio::Link row; URL is the short /l/<token>. Requires the
92
- # studio_links table (install the reference migration). The
93
- # unified scheme both apps move to.
94
- mattr_accessor :magic_link_store, default: :signed
92
+ # RETIRED (0.31.0) — magic links are ALWAYS Studio::Link rows now, so this
93
+ # reads :database and nothing else. Assigning :signed raises rather than
94
+ # silently downgrading: that store minted a ~350-character MessageVerifier
95
+ # blob whose EXPIRED form cannot be decoded, so an app on it could not tell
96
+ # whose dead link it was holding — which is exactly the fact
97
+ # Studio::LinkResolution needs to leave a live session alone. Requires the
98
+ # studio_links table, installed by `bin/rails studio_engine:install:migrations`
99
+ # (never hand-copied — a hand copy collides with the task's own copy on
100
+ # `class CreateStudioLinks`).
101
+ mattr_reader :magic_link_store, default: :database
102
+
103
+ # `to_s.to_sym`, not `to_sym`: this runs from an initializer, and nil or an
104
+ # Integer would raise NoMethodError — swallowing the explanation below with a
105
+ # message that says nothing about what to do. A blank falls through to the
106
+ # raise instead, so the operator reads the actual instruction.
107
+ def self.magic_link_store=(value)
108
+ return if value.to_s.to_sym == :database
109
+
110
+ raise ArgumentError,
111
+ "Studio.magic_link_store = #{value.inspect} is retired (studio-engine 0.31.0). " \
112
+ "Magic links are Studio::Link rows served at /l/<token>. Delete this line from " \
113
+ "config/initializers/studio.rb, then install the table with " \
114
+ "`bin/rails studio_engine:install:migrations && bin/rails db:migrate` — in that " \
115
+ "order, because this raise fires while the initializer loads and no rake task can " \
116
+ "boot until the line is gone."
117
+ end
95
118
 
96
119
  # Whether Studio.routes draws the magic_link + solana wallet routes. An app that
97
120
  # already defines its own auth routes (e.g. turf-monster, which has battle-tested
@@ -229,13 +252,13 @@ module Studio
229
252
  false
230
253
  end
231
254
 
232
- # True when the emailed/inbox magic-link URL is the short /l/<token> — i.e.
233
- # magic links are Studio::Link rows AND this app draws the /l routes. False =
234
- # the legacy /magic_link/<token> path: the :signed store, OR an app on the
235
- # :database store that keeps its own /magic_link route (e.g. turf-monster,
236
- # whose /l is already its landing-page namespace).
255
+ # True when the emailed/inbox magic-link URL is the short /l/<token> — the
256
+ # standard. False means this app draws its own token route instead and owns
257
+ # the matching consume: turf-monster keeps /magic_link/<token> because /l is
258
+ # already its landing-page namespace. Either way the TOKEN is the same short
259
+ # Studio::Link token; only the path in front of it differs.
237
260
  def self.magic_link_via_l_route?
238
- magic_link_store == :database && draw_link_routes
261
+ draw_link_routes
239
262
  end
240
263
 
241
264
  # The floor every developer-desk tool sits on: the local email inbox
@@ -393,18 +416,15 @@ module Studio
393
416
  get "_studio/local_review", to: "studio/local_reviews#show", as: :studio_local_review
394
417
  end
395
418
 
396
- # Passwordless email (magic link). Helpers: magic_link_request_path (POST
397
- # to request a link), magic_link_path(token) / magic_link_url(token:)
398
- # for the emailed GET confirmation page, and magic_link_consume_path(token)
399
- # for the scanner-safe POST consume. The token is a URL-safe
400
- # MessageVerifier blob but the constraint guards against a stray "."
401
- # segment.
419
+ # Passwordless email (magic link) — the REQUEST half only. Helper:
420
+ # magic_link_request_path (POST an email address, get a link mailed).
421
+ #
422
+ # The token-bearing half moved to /l/<token> below (0.31.0). There is one
423
+ # token format now a short Studio::Link row and one place that burns
424
+ # it, so the old /magic_link/:token confirm+consume pair would have been a
425
+ # second door onto the same lock.
402
426
  if Studio.draw_auth_routes && Studio.auth_method?(:magic_link)
403
- post "magic_link", to: "magic_links#create", as: :magic_link_request
404
- get "magic_link/:token", to: "magic_links#confirm", as: :magic_link,
405
- constraints: { token: %r{[^/]+} }
406
- post "magic_link/:token", to: "magic_links#consume", as: :magic_link_consume,
407
- constraints: { token: %r{[^/]+} }
427
+ post "magic_link", to: "magic_links#create", as: :magic_link_request
408
428
  end
409
429
 
410
430
  # Unified short-token links — /l/<token> for magic sign-in links + referral
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.0
4
+ version: 0.31.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-08 00:00:00.000000000 Z
11
+ date: 2026-08-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -220,7 +220,6 @@ files:
220
220
  - app/models/studio/model_page.rb
221
221
  - app/models/theme_setting.rb
222
222
  - app/services/google_oauth_validator.rb
223
- - app/services/magic_link.rb
224
223
  - app/services/studio/email_image.rb
225
224
  - app/views/components/_admin_dropdown.html.erb
226
225
  - app/views/components/_avatar.html.erb
@@ -247,7 +246,6 @@ files:
247
246
  - app/views/layouts/studio/_flash.html.erb
248
247
  - app/views/layouts/studio/_head.html.erb
249
248
  - app/views/layouts/studio/_smooth_load.html.erb
250
- - app/views/magic_links/confirm.html.erb
251
249
  - app/views/navbar/show.html.erb
252
250
  - app/views/registrations/new.html.erb
253
251
  - app/views/schema/index.html.erb
@@ -336,6 +334,7 @@ files:
336
334
  - lib/studio/engine.rb
337
335
  - lib/studio/environment_banner.rb
338
336
  - lib/studio/image_cache.rb
337
+ - lib/studio/link_resolution.rb
339
338
  - lib/studio/link_token.rb
340
339
  - lib/studio/mail_transport.rb
341
340
  - lib/studio/redis.rb
@@ -1,122 +0,0 @@
1
- # Unified create-or-login magic link.
2
- #
3
- # A magic link is a signed, short-lived, single-use token keyed on an EMAIL
4
- # (the user may not exist yet — clicking the link either logs them in or
5
- # creates the account). The token is a `message_verifier(token_name)` payload
6
- # carrying the email + a sanitized return_to + a random jti.
7
- #
8
- # Single-use is enforced with the jti: on `generate` we record the jti in
9
- # Rails.cache (Redis, cross-process); on `consume` we delete it and reject if
10
- # it was already gone (replay / second click). The signature already covers
11
- # tamper + expiry; the jti closes the replay gap.
12
- #
13
- # Token name + TTL come from Studio config (Studio.magic_link_token_name /
14
- # Studio.magic_link_ttl) so each app can tune them; the jti cache entry is
15
- # always given a few extra minutes so a still-valid token's jti is present.
16
- #
17
- # NOTE on test env: the test cache is :null_store, where writes/deletes are
18
- # no-ops and `delete` always returns false — enforcing single-use there would
19
- # reject every legitimate consume. So enforcement is skipped for non-tracking
20
- # stores; the service unit test injects a real MemoryStore to exercise it.
21
- #
22
- # Lifted into studio-engine (was turf-monster app/services/magic_link.rb).
23
- class MagicLink
24
- # Back-compat defaults. Behavior is driven by the `token_name` / `ttl` methods
25
- # (which read Studio config); these constants remain so existing consumer code
26
- # /tests referencing MagicLink::TTL keep working, and they equal the config
27
- # defaults.
28
- TOKEN_KEY = "magic_link_v1"
29
- TTL = 15.minutes
30
-
31
- class InvalidToken < StandardError; end
32
-
33
- Result = Struct.new(:email, :return_to, keyword_init: true)
34
-
35
- class << self
36
- # Test seam — defaults to Rails.cache. The service unit test sets this to
37
- # an ActiveSupport::Cache::MemoryStore to assert single-use, then resets it.
38
- attr_writer :cache
39
-
40
- def cache
41
- @cache || Rails.cache
42
- end
43
-
44
- def token_name
45
- Studio.magic_link_token_name
46
- end
47
-
48
- def ttl
49
- Studio.magic_link_ttl
50
- end
51
-
52
- # jti outlives the token so a valid token's jti is always still present.
53
- def jti_ttl
54
- ttl + 5.minutes
55
- end
56
-
57
- # Returns a signed token string. `return_to` is sanitized to a local path.
58
- # The MessageVerifier blob is standard base64 (can contain "/" and "+"),
59
- # which breaks the `%r{[^/]+}` route constraint once the payload is large
60
- # enough to emit a "/". Wrap it URL-safe so the token is always
61
- # [A-Za-z0-9_-]=, matching the route and surviving URL generation.
62
- def generate(email:, return_to: nil)
63
- normalized = normalize_email(email)
64
- jti = SecureRandom.hex(16)
65
- cache.write(jti_key(jti), normalized, expires_in: jti_ttl) if enforce_single_use?
66
- raw = verifier.generate(
67
- { email: normalized, return_to: sanitize_path(return_to), jti: jti, v: 1 },
68
- expires_in: ttl
69
- )
70
- Base64.urlsafe_encode64(raw)
71
- end
72
-
73
- # Verifies signature + expiry + single-use. Returns a Result or raises
74
- # InvalidToken. Idempotency is NOT offered — a consumed token is dead.
75
- def consume(token)
76
- raw = Base64.urlsafe_decode64(token.to_s)
77
- payload = verifier.verify(raw).with_indifferent_access
78
- raise InvalidToken, "unexpected token shape" unless payload[:v] == 1 && payload[:email].present?
79
-
80
- if enforce_single_use?
81
- # delete returns true only when the jti was still present
82
- raise InvalidToken, "link already used or expired" unless cache.delete(jti_key(payload[:jti]))
83
- elsif !Rails.env.test?
84
- # Single-use is disabled (non-tracking cache). Expected in :null_store
85
- # dev; in any other env it means replay protection is silently OFF —
86
- # tokens are replayable for their TTL. Surface it loudly.
87
- Rails.logger.warn("[MagicLink] single-use NOT enforced (cache=#{cache.class}); links are replayable until expiry")
88
- end
89
-
90
- Result.new(email: payload[:email], return_to: sanitize_path(payload[:return_to]))
91
- rescue ActiveSupport::MessageVerifier::InvalidSignature, ArgumentError
92
- # ArgumentError → malformed base64 (tampered/truncated token).
93
- raise InvalidToken, "invalid or expired link"
94
- end
95
-
96
- private
97
-
98
- def verifier
99
- Rails.application.message_verifier(token_name)
100
- end
101
-
102
- def jti_key(jti)
103
- "magic_link/jti/#{jti}"
104
- end
105
-
106
- def normalize_email(email)
107
- email.to_s.strip.downcase
108
- end
109
-
110
- # Only same-origin absolute paths survive; everything else (protocol-relative
111
- # "//evil", absolute URLs, blank) collapses to nil so callers fall back to a
112
- # default redirect.
113
- def sanitize_path(path)
114
- p = path.to_s
115
- p.start_with?("/") && !p.start_with?("//") ? p : nil
116
- end
117
-
118
- def enforce_single_use?
119
- !cache.is_a?(ActiveSupport::Cache::NullStore)
120
- end
121
- end
122
- end
@@ -1,2 +0,0 @@
1
- <%# Legacy /magic_link/:token interstitial. Shared body in studio/_confirm_interstitial. %>
2
- <%= render "studio/confirm_interstitial", consume_path: magic_link_consume_path(token: @token) %>