studio-engine 0.30.0 → 0.32.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: 445252feafe8e5767f83fcfc37ef8278a39e3cf02c10f050e1e89eb0f1dc779e
4
+ data.tar.gz: f99cd313622b6eaa1b94a4d20bd4d2b2bca026f9c44899e0a94cdfef2c18a19c
5
5
  SHA512:
6
- metadata.gz: ae3fee306fed9857d3421c005ac4006f46dda013d95009a0cde4daf6fc010a6c2d604e8ea173d7806985807e8b5603c57e6cef392a2b2fd00943d3db050d645d
7
- data.tar.gz: b629a74f76f684338f82e028e6a5833dd15289720da55fea4f25e08afa1549454bed48df1131f1338a8ac28880e9e86b9201765ab4c7aac043510eefe7a70797
6
+ metadata.gz: 67d422357f8bfe80704d7c61dcdfce194f86dd902d460837ec85424db4fe3a804d41516213fe2c8e171c304bca120c3b70af460f8b9df5c8bbc4f39993dafabc
7
+ data.tar.gz: cbcd30b10f5f9680ad79f60f541eca0afbc93e824522c914350e2f63cb4b4f47ba3bc48c4cd25afa9bc73ada4a9dbba08321b82c094651a63998eebf7dfbdf1a
data/CHANGELOG.md CHANGED
@@ -2,6 +2,178 @@
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.32.0 — Unreleased
6
+
7
+ **Readable ink on every theme, and the `btn-primary` label joins the token
8
+ contract.** (The token work merged after 0.31.0 was cut, so both land here.)
9
+
10
+ Dark- and light-mode `--color-text-muted` / `--color-text-secondary` no longer
11
+ hardcode slate grays: they derive from the theme base by a bounded contrast
12
+ search (`ColorScale.contrast_ratio` is new public API) that walks the blend
13
+ until the ink clears 3:1 / 4.5:1 on every emitted background — for any
14
+ operator-picked base, not just the fleet's. Fixed grays measured as low as
15
+ 2.05:1 on themed surfaces.
16
+
17
+ Consumers can now also set `--btn-primary-fg` (and `--btn-primary-fg-hover`
18
+ for the darker hover fill) on `:root` exactly like the existing
19
+ `--btn-secondary-*` tokens; a light brand primary (McRitchie Industries'
20
+ furnace orange) fails WCAG under the hardcoded white label. Defaults resolve
21
+ to the same white as before, so tokenless consumers are unchanged.
22
+
23
+ ## 0.31.0 — 2026-08-08
24
+
25
+ **One magic-link token format, one door, and a click that stops breaking your
26
+ session.** Two operator-reported faults, one root: the engine shipped two
27
+ magic-link stores side by side, and the legacy one could not support the
28
+ behavior the other needed.
29
+
30
+ The links themselves were the visible half — a `:signed` app mailed a
31
+ ~350-character `MessageVerifier` blob at `/magic_link/<token>`, wrapping four
32
+ lines of an email. The invisible half was worse: **clicking a link a second time
33
+ dumped a signed-in visitor on the login page.** Their cookie survived, but the
34
+ destination said otherwise, and the destination is what people believe. Every
35
+ dead token — used, expired, or unrecognized — funnelled into the same
36
+ `redirect_to login_path, alert:` no matter who was holding a session.
37
+
38
+ Fixing the second required retiring the first. An EXPIRED `MessageVerifier`
39
+ token cannot be decoded, so a `:signed` app could not tell whose dead link it
40
+ was holding — and "is this the visitor's own link?" is the question the whole
41
+ new behavior turns on. A `Studio::Link` row keeps the email past expiry.
42
+
43
+ ### Added
44
+
45
+ - **`Studio::LinkResolution`** — the click decision table as one pure,
46
+ dependency-free module (`lib/studio/link_resolution.rb`), so every cell is
47
+ unit-testable without a controller or a database. Three inputs (did this
48
+ caller burn the link, whose email it carries, who is signed in) resolve to one
49
+ of three actions:
50
+
51
+ | | nobody signed in | the link's own user | somebody else |
52
+ |------------------|------------------|---------------------|------------------|
53
+ | **live** (burned)| `:authenticate` | `:continue` | `:authenticate` |
54
+ | **used/expired** | `:dead` → login | `:dead` → return_to | `:dead` → home |
55
+ | **unknown token**| `:dead` → login | — | `:dead` → home |
56
+
57
+ The invariant running through it: **a dead link never touches the session.**
58
+ - **`:continue`** — a second click on your own still-live link burns the token
59
+ (so a forwarded email stays unusable) and then does nothing else. It
60
+ deliberately does NOT re-authenticate: a host that rotates the session on
61
+ sign-in would otherwise charge a re-click every scrap of session state the
62
+ visitor had built up. From their side it is indistinguishable from following a
63
+ plain link, which is the point.
64
+ - **Dead-link notices name the address and the reason** ("That sign-in link for
65
+ x@y.com has expired.") and, when a session is open, say so plainly ("You are
66
+ still signed in as a@b.com") instead of implying a logout that never happened.
67
+ - **`Studio::Link#burn`** — the non-raising sibling of `#consume!`, returning
68
+ whether THIS caller won the atomic single-use race, plus `#dead_status`
69
+ (`:used` / `:expired`) for the message. Winning the burn IS the proof the link
70
+ was live; a prior `live?` read is not.
71
+ - **`Studio::Link::MissingTable`** — a named error, pointing at the migration to
72
+ copy, in place of a bare `PG::UndefinedTable`. Every consumer pins the engine
73
+ `~> 0.x`, which admits any release below 1.0, so an app that never installed
74
+ the table picks up the row store on its next `bundle update` with nobody
75
+ having adopted anything deliberately. Its boot stays clean (nothing touches
76
+ the table until someone signs in), so without this the failure lands as an
77
+ unreadable adapter error on a real person's sign-in. Guarded by
78
+ `table_exists?`, not a message match, so it holds on any adapter and never
79
+ swallows an unrelated statement failure.
80
+ - **`Studio::LinkToken::TOKEN_LENGTH` / `TOKEN_LENGTH_BOUNDS` / `TOKEN_FORMAT`** —
81
+ the house standard, now asserted rather than described: every token is exactly
82
+ 16 URL-safe characters, inside a 10-20 character bound.
83
+ - **`Studio::LinkConsumption`** gains the whole flow (`preview_magic_link` for
84
+ the inert GET, `consume_magic_link` for the burning POST) plus overridable
85
+ hooks: `link_continue`, `link_dead`, `link_login_path`, `link_home_path`.
86
+ Apps customize by overriding a hook, never by re-deciding.
87
+ - **Two new suites**: `test/lib/studio/link_resolution_test.rb` (the table, cell
88
+ by cell, with the dead-link invariant swept across all nine cells) and
89
+ `test/integration/magic_link_flow_test.rb` (the same flow through a real HTTP
90
+ round trip against a real database — token burn, session cookie, scanner
91
+ prefetch, account switch, open-redirect refusal, and the burn race).
92
+
93
+ ### Changed
94
+
95
+ - **`Studio.magic_link_store` now reads `:database` and nothing else.** Assigning
96
+ `:signed` **raises at boot** with the migration to install, rather than
97
+ silently downgrading — an app that booted anyway would mint rows against a
98
+ table it never migrated and 500 on a real person's sign-in.
99
+ - **`Studio.magic_link_via_l_route?`** follows `draw_link_routes` alone.
100
+ - **`RegistrationsController`** mints through `Studio::MagicLinkIssuing` like
101
+ every other issuer, instead of calling the store directly.
102
+
103
+ ### Removed
104
+
105
+ - **`MagicLink`** (`app/services/magic_link.rb`), the stateless MessageVerifier
106
+ service, and its jti-in-Rails.cache replay guard.
107
+ - **`GET`/`POST /magic_link/:token`** and `MagicLinksController#confirm` /
108
+ `#consume`. `POST /magic_link` (request a link) stays. The token-bearing door
109
+ is `/l/<token>`, and only that.
110
+ - **`app/views/magic_links/confirm.html.erb`** — the `/l` interstitial
111
+ (`studio/links/confirm`) renders the same shared body.
112
+ - **`Studio.magic_link_token_name`** is vestigial: the accessor remains so an
113
+ un-updated initializer still boots, but nothing reads it. Delete the line.
114
+
115
+ ### Consumer migration (required)
116
+
117
+ **Until step 2 lands, the app's magic-link sign-in is broken** — no
118
+ `studio_links` table means the first mint raises `Studio::Link::MissingTable`.
119
+ Do these two in this order; they do not commute.
120
+
121
+ 1. Delete `config.magic_link_store` and `config.magic_link_token_name` from
122
+ `config/initializers/studio.rb`. **First**, because a leftover `= :signed`
123
+ raises while the initializer loads — and `Studio.configure` yields during
124
+ boot, so no rake task (including the migration install below) can run until
125
+ the line is gone.
126
+ 2. Install the `studio_links` table with `bin/rails
127
+ studio_engine:install:migrations && bin/rails db:migrate` — install ALL of
128
+ them, per `docs/NEW_APP_SETUP.md` § 5. Do **not** hand-copy the migration:
129
+ the task installs it as `<timestamp>_create_studio_links.studio_engine.rb`, a
130
+ hand copy keeps its own name, and both declare `class CreateStudioLinks`, so
131
+ `db:migrate` dies on `ActiveRecord::DuplicateMigrationNameError`.
132
+ 3. Replace any `MagicLink.generate` / `MagicLink.consume` call (including in
133
+ test helpers) with `Studio::Link.create_magic_link` / `Studio::Link#burn`.
134
+ 4. An app overriding the link controllers gets the new behavior by calling
135
+ `consume_magic_link` / `preview_magic_link` and overriding hooks. In
136
+ particular, move any `reset_session` into `sign_in_existing` only — the
137
+ `:continue` path must not reach it.
138
+ ## 0.30.1 — 2026-08-08
139
+
140
+ **`NEW_APP_SETUP.md` § 5 gave a command that does not exist.** 0.30.0 documented
141
+ the engine migration install as `bin/rails studio:install:migrations`; the real
142
+ task is **`studio_engine:install:migrations`**, and the copied files land with a
143
+ `.studio_engine.rb` suffix, not `.studio.rb`. The wrong spelling was inferred
144
+ from a consumer file that had been hand-copied rather than generated, and it
145
+ fails loudly (`Unrecognized command`) for anyone who follows the guide.
146
+
147
+ **And one of the copied migrations could fail the whole run.** The task copies
148
+ FOUR *reference* migrations, and `allow_null_image_cache_owner` runs
149
+ `change_column_null :image_caches` — which raised on any app without that table,
150
+ taking the entire `db:migrate` down with it. moms-app hit exactly that.
151
+
152
+ The obvious answer — "review what was copied and delete what doesn't apply" — is
153
+ wrong, and this release does NOT tell you to do it. `install:migrations` builds
154
+ its skip-list from the files **present**, so a deleted copy comes back with a
155
+ fresh timestamp on your next upgrade and fails again. The guard therefore lives
156
+ in the migration, and § 5 now says the simple thing: **install all of them.**
157
+
158
+ ### Fixed
159
+
160
+ - **`allow_null_image_cache_owner` no longer fails `db:migrate` on an app without
161
+ an `image_caches` table.** It ALTERS an app-owned table the engine cannot assume
162
+ exists, and unguarded it raised and took the whole migration run down with it.
163
+ It now no-ops when the table is absent, and still relaxes the owner columns when
164
+ it is present — both halves pinned by
165
+ `test/integration/image_cache_migration_guard_test.rb`.
166
+
167
+ Deleting the copied migration was never a workaround: `install:migrations`
168
+ builds its skip-list from the files **present**, so a deleted copy is re-copied
169
+ with a fresh timestamp on the next upgrade and fails again. Verified by
170
+ re-running the task against a real consumer.
171
+ - `NEW_APP_SETUP.md` § 5: correct task name, correct file suffix, and — now that
172
+ the migration guards itself — the simple instruction to install ALL of them and
173
+ re-run after each upgrade.
174
+ - `EMAIL_TRANSPORT.md`: was pointing at `railties:install:migrations`, which
175
+ copies the migrations of EVERY railtie in the bundle. Scoped to `studio_engine:`.
176
+
5
177
  ## 0.30.0 — 2026-08-08
6
178
 
7
179
  **The hub's link sidebar becomes the engine's out-of-the-box navigation.** New
@@ -94,7 +266,9 @@ are **unchanged** — their production hard-close still stands, which is also wh
94
266
  - **`NEW_APP_SETUP.md` § 9 no longer ships a hand-rolled banner to copy** — it
95
267
  renders the shared partial, and documents the QA/email reality.
96
268
  - **`NEW_APP_SETUP.md` § 5 now installs the engine migrations FIRST**
97
- (`bin/rails studio:install:migrations`). Omitting them is silent:
269
+ (~~`bin/rails studio:install:migrations`~~ **erratum, 0.30.1:** that command
270
+ does not exist; the correct task is `studio_engine:install:migrations`).
271
+ Omitting them is silent:
98
272
  `Studio::Email.deliver` records mail only when `studio_email_deliveries` exists
99
273
  and otherwise falls back to a plain `deliver_later`, so the app drops every
100
274
  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
 
@@ -71,6 +71,8 @@
71
71
  ADDITIVE in Tailwind v4 (both definitions emit and the engine's rules leak in),
72
72
  so forking is not a clean override. Set these on :root in the consumer's CSS:
73
73
 
74
+ --btn-primary-fg label color (default: #fff)
75
+ --btn-primary-fg-hover hover label (default: same as --btn-primary-fg)
74
76
  --btn-secondary-bg base fill (default: var(--color-success))
75
77
  --btn-secondary-bg-hover hover fill (default: same as base)
76
78
  --btn-secondary-hover-filter hover filter (default: brightness(0.9))
@@ -97,10 +99,18 @@
97
99
  }
98
100
  }
99
101
 
102
+ /* btn-primary's label is token-overridable like btn-secondary's fill: a light
103
+ brand primary (e.g. industries' furnace orange) fails contrast under a white
104
+ label, so such apps set --btn-primary-fg (and, when the darker hover fill
105
+ flips the readable choice, --btn-primary-fg-hover) instead of fighting the
106
+ @utility with unlayered overrides. Defaults resolve to the same white as
107
+ before (a literal #fff — the engine's both-modes guard bars raw
108
+ --color-white references, which only exist in Tailwind's compiled theme). */
100
109
  @utility btn-primary {
101
- @apply text-white;
110
+ color: var(--btn-primary-fg, #fff);
102
111
  background-color: var(--color-cta);
103
112
  &:hover {
113
+ color: var(--btn-primary-fg-hover, var(--btn-primary-fg, #fff));
104
114
  background-color: var(--color-cta-hover);
105
115
  }
106
116
  }
@@ -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."