studio-engine 0.29.1 → 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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +254 -0
  3. data/README.md +5 -4
  4. data/app/assets/tailwind/studio_engine/engine.css +18 -0
  5. data/app/controllers/concerns/studio/error_handling.rb +7 -2
  6. data/app/controllers/concerns/studio/link_consumption.rb +131 -11
  7. data/app/controllers/concerns/studio/magic_link_issuing.rb +15 -20
  8. data/app/controllers/magic_links_controller.rb +13 -40
  9. data/app/controllers/registrations_controller.rb +3 -1
  10. data/app/controllers/studio/links_controller.rb +17 -19
  11. data/app/controllers/studio/local_reviews_controller.rb +4 -4
  12. data/app/helpers/studio_sidebar_helper.rb +22 -0
  13. data/app/mailers/user_mailer.rb +8 -7
  14. data/app/models/studio/link.rb +55 -2
  15. data/app/views/components/_link_sidebar.html.erb +179 -0
  16. data/app/views/components/_link_sidebar_trigger.html.erb +21 -0
  17. data/app/views/components/_sidebar_panel.html.erb +63 -0
  18. data/app/views/components/_user_nav.html.erb +13 -7
  19. data/app/views/layouts/_navbar.html.erb +29 -5
  20. data/app/views/navbar/show.html.erb +4 -1
  21. data/app/views/studio/_confirm_interstitial.html.erb +4 -3
  22. data/app/views/studio/banners/_button.html.erb +10 -2
  23. data/app/views/studio/banners/_email_status_button.html.erb +25 -6
  24. data/app/views/studio/banners/_environment.html.erb +38 -13
  25. data/db/migrate/20260620000002_allow_null_image_cache_owner.rb +10 -0
  26. data/lib/studio/environment_banner.rb +56 -0
  27. data/lib/studio/link_resolution.rb +139 -0
  28. data/lib/studio/link_token.rb +13 -4
  29. data/lib/studio/sidebar_sections.rb +34 -0
  30. data/lib/studio/theme_resolver.rb +4 -2
  31. data/lib/studio/version.rb +1 -1
  32. data/lib/studio.rb +108 -28
  33. data/studio-engine.gemspec +5 -5
  34. metadata +14 -9
  35. data/app/services/magic_link.rb +0 -122
  36. data/app/views/magic_links/confirm.html.erb +0 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db4fb7663508ced21fce80ca29ec063c300c2826e0dadc511c266219303e449d
4
- data.tar.gz: b7e7c5cb133164b6d2a0521d4f08291540a78b4fb01a0be3b840f5413e35d8bb
3
+ metadata.gz: c36ecfe3e213e5621f205743728461c24d02912ed7e7bbf5109e0c15afd3225e
4
+ data.tar.gz: d92755cdb2ed3f7b8dca941bb514e2c725314056afbc83ee7fb190a4cc4a993c
5
5
  SHA512:
6
- metadata.gz: 328965e3979861ddc61b3c312a665b42b4e17d4d35a45851c3c76e9c538904e8096ba20725aa81fd45ba3e0509e8cf1fa764bbac70483a26afb5d8273bb43341
7
- data.tar.gz: c3323dfb24072f240935a1c3300dcf694d9c5ba22097fe2c1dc27ed04d0ee68cfb18df95844fe182e71622ccc6ba151dcb8ef3c5cc3488b5ac9959535e16c0c3
6
+ metadata.gz: 17c9f349695792402a9cbcb245159ebcba107b355edcdc9c5ae3b3d89d23303d0bd3a4bd96846d1d23c23417fbff47a0f73e0db887bd8880faeb4687e7e48c12
7
+ data.tar.gz: c65f117c47ced22be63a3747f0cd2b283f00a80c40802947a1003ada976749242ebd8340a511d89c9806d9449c6a3525eb1cbebb5fdd47d37cf334465d2e2644
data/CHANGELOG.md CHANGED
@@ -2,6 +2,260 @@
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
+
159
+ ## 0.30.0 — 2026-08-08
160
+
161
+ **The hub's link sidebar becomes the engine's out-of-the-box navigation.** New
162
+ apps get a default navigation surface without forking the navbar: declare
163
+ `Studio.sidebar_sections` (a static Array or a callable receiving the view
164
+ context; sections flagged `admin: true` show to admins only) and the engine
165
+ navbar mounts a trigger in its desktop icon rail and mobile sub-navbar, then
166
+ renders the slide-out link panels after the header. The family ships as
167
+ `components/_link_sidebar` (dual desktop/mobile panels + the engine-owned
168
+ Alpine `sidebars` store bridge, Turbo- and bfcache-safe), the generic
169
+ `components/_sidebar_panel` shell, and `components/_link_sidebar_trigger` —
170
+ all lifted from the mcritchie-studio hub. `engine.css` gains the
171
+ `studio-link-sidebar-layer` stacking utility and the Alpine `[x-cloak]`
172
+ pre-init rule. **Upgrade-safe by default:** `sidebar_sections` defaults to
173
+ `[]`, which renders nothing — existing consumers see zero change until they
174
+ opt in (pinned by `test/integration/sidebar_navbar_render_test.rb`). The
175
+ `html { overflow-x: clip }` slide guard ships inside the sidebar partial, not
176
+ globally. When the viewer's resolved sections carry an `admin: true` entry,
177
+ the sidebar replaces the engine admin dropdown (both use the cog glyph — one
178
+ gear, not two); public-only sections keep the dropdown. Resolution rules live
179
+ in `lib/studio/sidebar_sections.rb` (pure Ruby, unit-tested); docs in
180
+ `NEW_APP_SETUP.md` §4/§13 and `NAVBAR_SETUP.md`.
181
+
182
+ **Off-chain apps lose the user nav's dead space.** The default second row
183
+ (wallet address + level progress bar) renders only when it has something to
184
+ show — a wallet, a server level, or the `show_logout_link` link. A plain
185
+ app's signed-in nav collapses to one line instead of an empty progress strip.
186
+ The engine navbar's fixed-width `.user-nav-col` (reserved to seat a balance)
187
+ likewise applies only when `balance_html` is passed; without one the column
188
+ becomes `.user-nav-fit` (shrink-to-fit, `max-width` capped for username
189
+ truncation). turf-monster's dense nav is untouched: its users carry wallet +
190
+ level, and it ships its own navbar. Pinned by
191
+ `test/integration/user_nav_collapse_render_test.rb` and the user_nav view
192
+ tests.
193
+
194
+ **The dev/QA environment banner is now an engine standard a host adopts with ONE
195
+ render call, and it carries the Local Inbox link.** Every app hand-rolled its own
196
+ yellow "&lt;Env&gt; Environment" strip — this gem's own `NEW_APP_SETUP.md` § 9 shipped
197
+ the `<div>` to copy — so no two apps agreed on when it showed or what it said, and
198
+ none of them linked `/_studio/local_emails`. The engine already had the pieces
199
+ (`studio/banners/_environment` and friends); it just could not decide *whether* to
200
+ render, so no host could use it. It can now:
201
+
202
+ ```erb
203
+ <%= render "studio/banners/environment" %>
204
+ ```
205
+
206
+ No `unless Rails.env.production?` around it, no host `show_environment_banner?`,
207
+ no forked buttons. The partial owns all three decisions — whether it appears
208
+ (everywhere except real production; a QA app is Rails-production but a review
209
+ target, so `QA_ENV=true` re-opens it), what it says (`"QA Environment ·
210
+ Non-production"` on QA), and whether the inbox is linkable.
211
+
212
+ **The inbox link tells the truth.** `Studio.local_inbox_reachable?` consults the
213
+ SAME gate `Studio::LocalEmailsController` enforces, so the banner can never
214
+ advertise a page that answers 404. Where the viewer resolves (a developer desk)
215
+ you get a link; where it does not (QA — Rails-production, remote requests) the
216
+ button degrades to an inert `role="status"` chip that still reports the connector
217
+ and whether mail is sending or captured. This generalizes the degradation
218
+ turf-monster had already built for itself.
219
+
220
+ Purely additive. `Studio.local_tool_enabled?` and `Studio.local_email_capture?`
221
+ are **unchanged** — their production hard-close still stands, which is also why
222
+ `LOCAL_EMAIL_CAPTURE=1` on a QA dyno does nothing and QA still sends real mail.
223
+
224
+ ### Added
225
+
226
+ - **`studio/banners/environment` self-gates** and accepts `preview:` (suppress
227
+ inside a navbar-preview render — a duplicate `vt-pinned-header` silently kills
228
+ view transitions), `extra:` (message segments), `devnet:`, and
229
+ `environment_label:`.
230
+ - **`Studio::EnvironmentBanner`** — the show/label rules as dependency-free Ruby,
231
+ unit-tested directly so the suite exercises the shipped code, not a copy.
232
+ - **`Studio.qa_environment?` / `.show_environment_banner?` /
233
+ `.environment_banner_message` / `.local_inbox_reachable?` / `.rails_env_name`.**
234
+ `qa_environment?` reads the existing `QA_ENV` the release conductor already sets
235
+ on every QA app — no new variable.
236
+ - **`studio/banners/button` gained `as: :status`** — the same chrome, inert, for a
237
+ fact we can report but not navigate to.
238
+
239
+ ### Fixed
240
+
241
+ - **`studio/banners/button` dropped every attribute on a block render.**
242
+ `tag.span(hash) { }` reads the hash as CONTENT, so the options never reached the
243
+ tag. Splatted as keywords now. Latent until `as: :status` became the first block
244
+ path to use it.
245
+
246
+ ### Docs
247
+
248
+ - **`NEW_APP_SETUP.md` § 9 no longer ships a hand-rolled banner to copy** — it
249
+ renders the shared partial, and documents the QA/email reality.
250
+ - **`NEW_APP_SETUP.md` § 5 now installs the engine migrations FIRST**
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:
254
+ `Studio::Email.deliver` records mail only when `studio_email_deliveries` exists
255
+ and otherwise falls back to a plain `deliver_later`, so the app drops every
256
+ captured email and shows an empty inbox. Exactly the mcritchie-industries bug,
257
+ fixed 2026-08-08.
258
+
5
259
  ## 0.29.1 — 2026-07-29
6
260
 
7
261
  **`Studio::Board::Rankable#reposition!` is now atomic — a mid-loop failure can no
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Shared Rails engine for McRitchie apps. Provides authentication, error handling, dynamic theming, and common concerns used by [McRitchie Studio](https://app.mcritchie.studio) and [Turf Monster](https://app.turfmonster.media).
4
4
 
5
- > **Part of the McRitchie ecosystem** — see [`ECOSYSTEM.md`](https://github.com/amcritchie/mcritchie-studio/blob/main/docs/ECOSYSTEM.md) for the 5-repo map; [`house-burn-down.md`](https://github.com/amcritchie/mcritchie-studio/blob/main/docs/agents/system/house-burn-down.md) for fresh-Mac recovery.
5
+ > **Part of the McRitchie ecosystem** — see [`ECOSYSTEM.md`](https://github.com/McRitchie-Studio/mcritchie-studio/blob/main/docs/ECOSYSTEM.md) for the 5-repo map; [`house-burn-down.md`](https://github.com/McRitchie-Studio/mcritchie-studio/blob/main/docs/agents/system/house-burn-down.md) for fresh-Mac recovery.
6
6
 
7
7
  ## Installation
8
8
 
@@ -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
 
@@ -280,4 +281,4 @@ For short local experiments, temporarily point a consumer Gemfile at `path: "../
280
281
  Use the docs in [`docs/`](./docs) for engine setup, release, email transport,
281
282
  and host-app contracts. Current cross-repo setup, ports, credentials, and
282
283
  workflow guidance live in McRitchie Studio's
283
- [`docs/agents/`](https://github.com/amcritchie/mcritchie-studio/tree/main/docs/agents).
284
+ [`docs/agents/`](https://github.com/McRitchie-Studio/mcritchie-studio/tree/main/docs/agents).
@@ -181,6 +181,24 @@
181
181
  @apply px-8 py-3 text-base rounded-xl shadow-sm;
182
182
  }
183
183
 
184
+ /* -- Link sidebar (out-of-the-box navigation) ------------------------------
185
+ Paired with components/_link_sidebar + _link_sidebar_trigger and the
186
+ Studio.sidebar_sections config. The layer sits above the sticky header
187
+ (z-50) and the modal host. Inert without the panel in the DOM, so apps
188
+ that declare no sections are unaffected. */
189
+
190
+ @utility studio-link-sidebar-layer {
191
+ z-index: 10000;
192
+ }
193
+
194
+ /* Alpine's pre-init cloak. Engine partials (sidebar panels, dropdowns, the
195
+ modal host) mark themselves x-cloak so they stay hidden until Alpine boots
196
+ and x-show takes over. Engine-owned so every consumer gets the rule without
197
+ carrying the one-liner app-side; a duplicate app copy is harmless. */
198
+ [x-cloak] {
199
+ display: none !important;
200
+ }
201
+
184
202
  /* -- Smooth-load convention ------------------------------------------------
185
203
  Paired with layouts/studio/_smooth_load.html.erb (the Studio.smooth_load
186
204
  metas that opt Turbo into document.startViewTransition). These rules are
@@ -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