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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +254 -0
- data/README.md +5 -4
- data/app/assets/tailwind/studio_engine/engine.css +18 -0
- data/app/controllers/concerns/studio/error_handling.rb +7 -2
- data/app/controllers/concerns/studio/link_consumption.rb +131 -11
- data/app/controllers/concerns/studio/magic_link_issuing.rb +15 -20
- data/app/controllers/magic_links_controller.rb +13 -40
- data/app/controllers/registrations_controller.rb +3 -1
- data/app/controllers/studio/links_controller.rb +17 -19
- data/app/controllers/studio/local_reviews_controller.rb +4 -4
- data/app/helpers/studio_sidebar_helper.rb +22 -0
- data/app/mailers/user_mailer.rb +8 -7
- data/app/models/studio/link.rb +55 -2
- data/app/views/components/_link_sidebar.html.erb +179 -0
- data/app/views/components/_link_sidebar_trigger.html.erb +21 -0
- data/app/views/components/_sidebar_panel.html.erb +63 -0
- data/app/views/components/_user_nav.html.erb +13 -7
- data/app/views/layouts/_navbar.html.erb +29 -5
- data/app/views/navbar/show.html.erb +4 -1
- data/app/views/studio/_confirm_interstitial.html.erb +4 -3
- data/app/views/studio/banners/_button.html.erb +10 -2
- data/app/views/studio/banners/_email_status_button.html.erb +25 -6
- data/app/views/studio/banners/_environment.html.erb +38 -13
- data/db/migrate/20260620000002_allow_null_image_cache_owner.rb +10 -0
- data/lib/studio/environment_banner.rb +56 -0
- data/lib/studio/link_resolution.rb +139 -0
- data/lib/studio/link_token.rb +13 -4
- data/lib/studio/sidebar_sections.rb +34 -0
- data/lib/studio/theme_resolver.rb +4 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +108 -28
- data/studio-engine.gemspec +5 -5
- metadata +14 -9
- data/app/services/magic_link.rb +0 -122
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c36ecfe3e213e5621f205743728461c24d02912ed7e7bbf5109e0c15afd3225e
|
|
4
|
+
data.tar.gz: d92755cdb2ed3f7b8dca941bb514e2c725314056afbc83ee7fb190a4cc4a993c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 "<Env> 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/
|
|
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`,
|
|
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/
|
|
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
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
98
|
+
verify_email_ownership(user)
|
|
30
99
|
set_app_session(user)
|
|
31
|
-
redirect_to(
|
|
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
|
|
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
|
|
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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# wholesale brings its own issuing
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|