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.
@@ -1,6 +1,6 @@
1
1
  module Studio
2
- # The unified short-token link entry point — GET/POST /l/<token>. Dispatches by
3
- # Studio::Link#kind:
2
+ # The short-token link entry point — GET/POST /l/<token>, the only magic-link
3
+ # door the engine draws. Dispatches by Studio::Link#kind:
4
4
  #
5
5
  # magic_link → scanner-safe confirm interstitial (GET, inert) that auto-POSTs
6
6
  # to #consume, the ONLY place the single-use token is burned +
@@ -9,6 +9,10 @@ module Studio
9
9
  # the link's target (or root). Reusable + safe to prefetch, so
10
10
  # GET does the work (no POST step).
11
11
  #
12
+ # Every decision about what a magic-link click DOES lives in
13
+ # Studio::LinkConsumption / Studio::LinkResolution, not here — including the
14
+ # invariant that a dead link leaves the visitor's session untouched.
15
+ #
12
16
  # Namespaced (not top-level Links) because mcritchie-studio already owns a
13
17
  # public /links linktree (top-level LinksController). Apps needing richer
14
18
  # post-consume routing (contest landing, picks rehydration, age-gate) define
@@ -25,30 +29,24 @@ module Studio
25
29
  response.set_header("Referrer-Policy", "strict-origin")
26
30
  @link = Studio::Link.find_by(token: params[:token])
27
31
 
28
- case @link&.kind
29
- when "magic_link"
30
- @token = params[:token]
31
- render :confirm
32
- when "referral"
32
+ if @link&.kind == "referral"
33
33
  capture_referral(@link)
34
- redirect_to(@link.target || root_path)
35
- else
36
- redirect_to login_path, alert: "That link is invalid or has expired. Request a fresh one below."
34
+ return redirect_to(@link.target || root_path)
37
35
  end
36
+
37
+ # A magic link, or a token with no row behind it. preview_magic_link is
38
+ # inert — it never burns — and settles a dead link itself rather than
39
+ # sending the visitor through a spinner that only POSTs to learn the same.
40
+ @token = params[:token]
41
+ render :confirm if preview_magic_link(@link&.kind == "magic_link" ? @link : nil) == :live
38
42
  end
39
43
 
40
44
  # POST /l/:token — authoritative magic-link consume. Only magic_link kinds are
41
- # consumable here; referral links are reusable and handled entirely on GET.
45
+ # consumable here; referral links are reusable and handled entirely on GET,
46
+ # so a referral token scoped out below reads as an unknown token.
42
47
  def consume
43
48
  response.set_header("Referrer-Policy", "strict-origin")
44
- link = Studio::Link.find_by(token: params[:token])
45
- raise Studio::Link::InvalidToken, "not a magic link" unless link&.kind == "magic_link"
46
-
47
- link.consume! # burns the single-use token; raises if already used / expired
48
- user = User.find_by(email: link.email)
49
- user ? sign_in_existing(user, link) : sign_up_new(link)
50
- rescue Studio::Link::InvalidToken
51
- redirect_to login_path, alert: "That sign-in link is invalid or has expired. Request a fresh one below."
49
+ consume_magic_link(Studio::Link.magic_links.find_by(token: params[:token]))
52
50
  end
53
51
 
54
52
  private
@@ -30,10 +30,10 @@ module Studio
30
30
  email = Studio::LinkToken.normalize_email(params[:email])
31
31
  return redirect_to(login_path, alert: MISSING_EMAIL) unless email.match?(URI::MailTo::EMAIL_REGEXP)
32
32
 
33
- # return_to is passed through raw: BOTH stores sanitize it to a same-origin
34
- # path on the way in (Studio::Link.create_magic_link and MagicLink.generate
35
- # each call Studio::LinkToken.sanitize_path). Re-sanitizing here would be a
36
- # second spelling of a rule that already has one owner — and a mutation run
33
+ # return_to is passed through raw: the store sanitizes it to a same-origin
34
+ # path on the way in (Studio::Link.create_magic_link calls
35
+ # Studio::LinkToken.sanitize_path). Re-sanitizing here would be a second
36
+ # spelling of a rule that already has one owner — and a mutation run
37
37
  # confirmed it guards nothing the store does not already guard.
38
38
  token = issue_magic_link(email, params[:return_to])
39
39
  redirect_to magic_link_url_for(token), allow_other_host: false
@@ -1,8 +1,7 @@
1
1
  class UserMailer < ApplicationMailer
2
- # magic_link_url_for — the URL that consumes the token, matched to
3
- # Studio.magic_link_store. Shared with the issuers that MINT the token
4
- # (MagicLinksController, Studio::LocalReviewsController) so the two halves
5
- # cannot drift apart.
2
+ # magic_link_url_for — the URL that consumes the token. Shared with the
3
+ # issuers that MINT it (MagicLinksController, RegistrationsController,
4
+ # Studio::LocalReviewsController) so the two halves cannot drift apart.
6
5
  include Studio::MagicLinkIssuing
7
6
 
8
7
  # Branded shell (banner + card) for engine-sent UserMailer emails. An app with
@@ -10,9 +9,11 @@ class UserMailer < ApplicationMailer
10
9
  layout "branded_mailer"
11
10
 
12
11
  # Passwordless sign-in link. `email` is a raw string (the recipient may not
13
- # have an account yet). Token is a signed MagicLink payload (email + return_to
14
- # + jti, single-use). Clicking the link logs the recipient in or creates their
15
- # account. App-name-aware so the same template serves every Studio app.
12
+ # have an account yet). The token is a Studio::Link row's short token 16
13
+ # URL-safe characters, single-use, expiring and the email + return_to it
14
+ # stands for stay in the row, off the wire. Clicking the link logs the
15
+ # recipient in or creates their account. App-name-aware so the same template
16
+ # serves every Studio app.
16
17
  #
17
18
  # Engine GENERIC base. An app needing richer copy (e.g. turf-monster's
18
19
  # contest-aware variant) defines its own UserMailer, which wins.
@@ -11,13 +11,20 @@ module Studio
11
11
  # discriminator. Replaces the engine's stateless MessageVerifier MagicLink
12
12
  # service for mcritchie-studio so both apps share one short-token scheme.
13
13
  #
14
- # Like Studio::EmailDelivery, the table lives in each consumer app (copy the
15
- # reference migration in db/migrate); this model is shipped by the gem.
14
+ # Like Studio::EmailDelivery, the table lives in each consumer app installed
15
+ # by `bin/rails studio_engine:install:migrations`, never hand-copied (a hand
16
+ # copy collides with the task's own copy on `class CreateStudioLinks`). This
17
+ # model is shipped by the gem.
16
18
  class Link < ApplicationRecord
17
19
  self.table_name = "studio_links"
18
20
 
19
21
  class InvalidToken < StandardError; end
20
22
 
23
+ # The app enabled :magic_link but never installed the table. Raised in place
24
+ # of a bare PG::UndefinedTable so the first person to hit it reads the fix
25
+ # instead of an adapter error — see mint!.
26
+ class MissingTable < StandardError; end
27
+
21
28
  belongs_to :linkable, polymorphic: true, optional: true
22
29
 
23
30
  validates :kind, inclusion: { in: Studio::LinkToken::KINDS }
@@ -76,6 +83,16 @@ module Studio
76
83
 
77
84
  # create! with a fresh random token, retrying the (astronomically rare)
78
85
  # unique-index collision a couple of times before surfacing the error.
86
+ #
87
+ # The MissingTable rescue exists because every consumer pins the engine as
88
+ # `~> 0.x`, which admits any release below 1.0 — so an app that never
89
+ # installed this table picks up the row store on its next `bundle update`
90
+ # whether or not anyone adopted it deliberately. Its boot is fine (nothing
91
+ # touches the table until someone signs in), and the failure then lands as
92
+ # a bare PG::UndefinedTable on a real person's sign-in. Naming the fix
93
+ # there is the difference between a five-minute repair and an outage
94
+ # nobody can read. The check is `table_exists?`, not a message match, so
95
+ # it holds on any adapter and never swallows an unrelated failure.
79
96
  def mint!(attrs)
80
97
  3.times do
81
98
  return create!(attrs.merge(token: Studio::LinkToken.generate))
@@ -83,6 +100,14 @@ module Studio
83
100
  next
84
101
  end
85
102
  create!(attrs.merge(token: Studio::LinkToken.generate))
103
+ rescue ActiveRecord::StatementInvalid
104
+ raise if table_exists?
105
+
106
+ raise MissingTable,
107
+ "studio-engine magic links need the studio_links table, and #{Studio.app_name} has no " \
108
+ "such table. Run `bin/rails studio_engine:install:migrations && bin/rails db:migrate` " \
109
+ "(install ALL of them). Do not hand-copy the migration — it collides with the task's " \
110
+ "own copy on `class CreateStudioLinks`."
86
111
  end
87
112
  end
88
113
 
@@ -106,6 +131,34 @@ module Studio
106
131
  self
107
132
  end
108
133
 
134
+ # The non-raising sibling of #consume!, and the one the click flow uses.
135
+ # Returns whether THIS caller won the burn — false for a link that was
136
+ # already used, has expired, or lost the atomic race to a concurrent click.
137
+ #
138
+ # Why a boolean and not the exception: "the link was dead" is not an error
139
+ # here, it is one of the two normal outcomes, and the branch it feeds
140
+ # (Studio::LinkResolution) needs the answer as data. Reloads on a loss so
141
+ # the caller reads the row's settled state (consumed_at / expires_at) rather
142
+ # than the copy it held before the race.
143
+ def burn
144
+ consume!
145
+ true
146
+ rescue InvalidToken
147
+ reload
148
+ false
149
+ end
150
+
151
+ # How a failed burn should be described. Only meaningful once #burn has
152
+ # returned false (or on a link that was never burned at all).
153
+ def dead_status
154
+ return :used if single_use? && consumed?
155
+ return :expired if expired?
156
+
157
+ # Neither flag is set but the burn did not land: a concurrent click won
158
+ # it between our read and our write. Same story for the reader.
159
+ :used
160
+ end
161
+
109
162
  def single_use?
110
163
  Studio::LinkToken.single_use?(kind)
111
164
  end
@@ -1,9 +1,10 @@
1
1
  <%#
2
2
  Shared scanner-safe sign-in interstitial. The GET that renders this is inert;
3
3
  the page auto-POSTs the CSRF-protected form to `consume_path` (the only place
4
- a single-use token is burned). Used by both MagicLinksController#confirm
5
- (consume_path: magic_link_consume_path) and Studio::LinksController#show
6
- (consume_path: link_consume_path). Rendered with layout false (full document).
4
+ a single-use token is burned). Rendered by Studio::LinksController#show
5
+ (consume_path: link_consume_path) the engine's only token door since 0.31.0
6
+ and by any app drawing its own token route. Rendered with layout false
7
+ (full document).
7
8
 
8
9
  Local: consume_path — the POST target that burns the token + signs in.
9
10
  %>
@@ -3,6 +3,16 @@
3
3
  # migration; each consumer app installs its own copy (the table is app-owned).
4
4
  class AllowNullImageCacheOwner < ActiveRecord::Migration[7.2]
5
5
  def change
6
+ # No-op on an app that doesn't use ImageCache. `image_caches` is app-owned,
7
+ # so an app can install the engine's migrations without having that table —
8
+ # and unguarded, this raised and failed the whole `db:migrate`.
9
+ #
10
+ # Deleting the copy is NOT a workaround: install:migrations builds its
11
+ # skip-list from the files PRESENT, so a deleted copy is re-copied with a
12
+ # fresh timestamp on the next upgrade and fails again. The guard has to live
13
+ # here, in the migration, or it doesn't hold.
14
+ return unless table_exists?(:image_caches)
15
+
6
16
  change_column_null :image_caches, :owner_type, true
7
17
  change_column_null :image_caches, :owner_id, true
8
18
  end
@@ -72,6 +72,19 @@ module Studio
72
72
  "#%02X%02X%02X" % [r.clamp(0, 255), g.clamp(0, 255), b.clamp(0, 255)]
73
73
  end
74
74
 
75
+ # WCAG 2.x relative luminance (sRGB linearized).
76
+ def self.relative_luminance(hex)
77
+ r, g, b = hex_to_rgb(hex).map { |c| c / 255.0 }
78
+ lin = [r, g, b].map { |c| c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055)**2.4 }
79
+ (0.2126 * lin[0]) + (0.7152 * lin[1]) + (0.0722 * lin[2])
80
+ end
81
+
82
+ # WCAG contrast ratio between two hex colors (1.0..21.0).
83
+ def self.contrast_ratio(a, b)
84
+ hi, lo = [relative_luminance(a), relative_luminance(b)].minmax.reverse
85
+ (hi + 0.05) / (lo + 0.05)
86
+ end
87
+
75
88
  def self.with_opacity(hex, opacity)
76
89
  r, g, b = hex_to_rgb(hex)
77
90
  "rgba(#{r},#{g},#{b},#{opacity})"
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "link_token"
4
+
5
+ module Studio
6
+ # What a magic-link click should DO — the whole decision as one pure table,
7
+ # free of ActiveRecord and of the controller, so every cell is unit-testable.
8
+ #
9
+ # A click has three inputs: whether this caller BURNED the link (won the
10
+ # single-use race), whose email the link carries, and who is already signed
11
+ # in. Six cells fall out of that, and one invariant runs through them:
12
+ #
13
+ # **A dead link never touches the session.**
14
+ #
15
+ # Before this table, every dead token — used, expired, or unknown — ended at
16
+ # `redirect_to login_path, alert: "invalid or has expired"`, whatever session
17
+ # the visitor was holding. So clicking your own link a second time dumped you
18
+ # on a sign-in page, which reads as being logged out. It never was: the cookie
19
+ # survived, but the destination said otherwise. Now a dead link is at worst a
20
+ # notice, and at best silent.
21
+ #
22
+ # The table (rows = link state, columns = who is signed in):
23
+ #
24
+ # | nobody | the link's own user | somebody else
25
+ # -------------+-----------------+---------------------+------------------
26
+ # live (burned)| :authenticate | :continue | :authenticate
27
+ # used/expired | :dead → login | :dead → return_to | :dead → home
28
+ # unknown token| :dead → login | — | :dead → home
29
+ #
30
+ # `:continue` is the cell the operator asked for by name: a second click on
31
+ # your own still-live link must be "no material difference — just a redirect".
32
+ # It burns the token (so nobody replays it later) and deliberately does NOT
33
+ # re-authenticate, because a host that rotates the session on sign-in — as
34
+ # turf-monster does — would otherwise charge a re-click the price of every
35
+ # scrap of session state the visitor had built up.
36
+ module LinkResolution
37
+ # How the click found the link.
38
+ # :claimed — this caller burned a live link (the only status that authenticates)
39
+ # :used — the row exists and was already consumed, or lost the burn race
40
+ # :expired — the row exists and is past expires_at
41
+ # :unknown — no row for this token, or the token is not a magic link
42
+ STATUSES = %i[claimed used expired unknown].freeze
43
+
44
+ # :authenticate — establish a session for the link's email (sign in or sign up)
45
+ # :continue — the viewer already IS the link's user: keep the session as
46
+ # it stands and land them on the link's destination
47
+ # :dead — do not touch the session at all
48
+ ACTIONS = %i[authenticate continue dead].freeze
49
+
50
+ # Where the click lands.
51
+ # :return_to — the link's own destination (falling back to home)
52
+ # :home — the app root; used when the link belongs to someone else,
53
+ # so its destination is not ours to follow
54
+ # :login — the sign-in page; only ever for a visitor with no session
55
+ DESTINATIONS = %i[return_to home login].freeze
56
+
57
+ Outcome = Struct.new(:action, :destination, :message, :level, keyword_init: true) do
58
+ def authenticate?
59
+ action == :authenticate
60
+ end
61
+
62
+ def continue?
63
+ action == :continue
64
+ end
65
+
66
+ def dead?
67
+ action == :dead
68
+ end
69
+
70
+ # A silent outcome shows the visitor nothing — the "no material
71
+ # difference" re-click.
72
+ def silent?
73
+ message.nil?
74
+ end
75
+ end
76
+
77
+ module_function
78
+
79
+ # @param status [Symbol] one of STATUSES
80
+ # @param link_email [String, nil] the email the link signs in (nil = unknown token)
81
+ # @param session_email [String, nil] the currently signed-in user's email
82
+ # @return [Outcome]
83
+ def call(status:, link_email: nil, session_email: nil)
84
+ raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
85
+
86
+ own = own_link?(link_email, session_email)
87
+
88
+ if status == :claimed
89
+ return Outcome.new(action: :continue, destination: :return_to) if own
90
+
91
+ # Nobody signed in, or somebody else signed in: both establish a session
92
+ # for the link's email. The second case is the deliberate account switch
93
+ # — a live link is proof of ownership, so it outranks the open session.
94
+ return Outcome.new(action: :authenticate, destination: :return_to)
95
+ end
96
+
97
+ # Dead from here down: no branch below may write to the session.
98
+ return Outcome.new(action: :dead, destination: :return_to) if own
99
+
100
+ if Studio::LinkToken.normalize_email(session_email).empty?
101
+ Outcome.new(action: :dead, destination: :login, level: :alert,
102
+ message: dead_message(status: status, link_email: link_email))
103
+ else
104
+ Outcome.new(action: :dead, destination: :home, level: :notice,
105
+ message: dead_message(status: status, link_email: link_email,
106
+ session_email: session_email))
107
+ end
108
+ end
109
+
110
+ # Same email, both sides present. A blank on either side is never a match —
111
+ # an unknown token (no email) must not read as "your own link" just because
112
+ # nobody is signed in.
113
+ def own_link?(link_email, session_email)
114
+ link = Studio::LinkToken.normalize_email(link_email)
115
+ seat = Studio::LinkToken.normalize_email(session_email)
116
+ !link.empty? && link == seat
117
+ end
118
+
119
+ # The notice a dead link earns. It names the address the link was for and
120
+ # why it failed — the detail that turns "something went wrong" into a
121
+ # decision the reader can act on — and, when a session is open, says so
122
+ # plainly, because the whole point is that nothing was lost.
123
+ def dead_message(status:, link_email: nil, session_email: nil)
124
+ addressee = Studio::LinkToken.normalize_email(link_email)
125
+ who = addressee.empty? ? "" : " for #{addressee}"
126
+ why = case status
127
+ when :expired then "has expired"
128
+ when :used then "was already used"
129
+ else "is no longer valid"
130
+ end
131
+
132
+ base = "That sign-in link#{who} #{why}."
133
+ seat = Studio::LinkToken.normalize_email(session_email)
134
+ return "#{base} Request a fresh one below." if seat.empty?
135
+
136
+ "#{base} You are still signed in as #{seat} — request a fresh link to switch accounts."
137
+ end
138
+ end
139
+ end
@@ -18,10 +18,19 @@ module Studio
18
18
  # they are deliberately NOT single-use.
19
19
  SINGLE_USE_KINDS = %w[magic_link].freeze
20
20
 
21
- # 96 bits of entropy~16 URL-safe chars (e.g. "PP-PDbEj5V3-aNh4"). Short
22
- # enough to keep the URL clean, far too large to brute-force — especially
23
- # for single-use, expiring magic links. Matches turf-monster's proven format.
24
- TOKEN_BYTES = 12
21
+ # THE HOUSE TOKEN STANDARD: 12 random bytes exactly 16 URL-safe
22
+ # characters (e.g. "PP-PDbEj5V3-aNh4"). 96 bits of entropy short enough
23
+ # that the whole link fits on one line of an email, far too large to
24
+ # brute-force, especially for a single-use token that expires in minutes.
25
+ #
26
+ # 16 sits mid-range in the house bound of 10-20 characters (TOKEN_LENGTH_
27
+ # BOUNDS), which is the number a reader should sanity-check a link against.
28
+ # urlsafe_base64 emits 4 characters per 3 bytes with no padding, so the
29
+ # length is exact, not approximate — every token is the same width.
30
+ TOKEN_BYTES = 12
31
+ TOKEN_LENGTH = 16
32
+ TOKEN_LENGTH_BOUNDS = (10..20).freeze
33
+ TOKEN_FORMAT = /\A[A-Za-z0-9_-]+\z/
25
34
 
26
35
  module_function
27
36
 
@@ -39,8 +39,18 @@ module Studio
39
39
  "--color-inset" => ColorScale.darken(dark_base, 0.43),
40
40
  "--color-text" => "#ffffff",
41
41
  "--color-text-body" => "#e2e8f0",
42
- "--color-text-secondary" => "#94a3b8",
43
- "--color-text-muted" => "#64748b",
42
+ # Derived by a bounded contrast search, not hardcoded: fixed slate
43
+ # grays fail WCAG on themes whose dark base lifts the surface (a
44
+ # hardcoded #64748b measured 2.14:1 on a navy surface), and any FIXED
45
+ # blend amount is tuned to particular hexes — the operator can pick an
46
+ # arbitrary base in the theme editor. contrast_ink walks the blend up
47
+ # until the ink clears its target on every emitted dark surface
48
+ # (clamped at pure white for pathological bases). Note the blend
49
+ # DESATURATES toward gray; only strongly-tinted bases keep a cast.
50
+ "--color-text-secondary" => contrast_ink(dark_base, direction: :lighten, start: 0.70, target: 4.5,
51
+ against: dark_surfaces(dark_base)),
52
+ "--color-text-muted" => contrast_ink(dark_base, direction: :lighten, start: 0.55, target: 3.0,
53
+ against: dark_surfaces(dark_base)),
44
54
  "--color-border" => ColorScale.with_opacity(border_rgb, 0.2),
45
55
  "--color-border-strong" => ColorScale.with_opacity(border_rgb, 0.4),
46
56
  "--color-shadow" => "transparent",
@@ -48,10 +58,41 @@ module Studio
48
58
  "--color-cta-hover" => ColorScale.darken(primary, 0.30),
49
59
  "--color-success" => colors[:success] || "#4BAF50",
50
60
  "--color-warning" => colors[:warning] || "#FF7C47",
51
- "--color-danger" => colors[:danger] || "#EF4444"
61
+ "--color-danger" => colors[:danger] || "#EF4444",
62
+ "--color-accent" => colors[:accent] || "#F72585"
52
63
  }
53
64
  end
54
65
 
66
+ # Every dark-mode background a text var can sit on (page, surface,
67
+ # surface-alt, inset) — the ink must clear its target on ALL of them.
68
+ def dark_surfaces(dark_base)
69
+ [ dark_base,
70
+ ColorScale.lighten(dark_base, 0.15),
71
+ ColorScale.darken(dark_base, 0.14),
72
+ ColorScale.darken(dark_base, 0.43) ]
73
+ end
74
+
75
+ def light_surfaces(light_base)
76
+ [ light_base,
77
+ "#ffffff",
78
+ ColorScale.darken(light_base, 0.03),
79
+ ColorScale.darken(light_base, 0.08) ]
80
+ end
81
+
82
+ # Bounded, clamped search: raise the blend amount from `start` until the
83
+ # ink clears `target` contrast against every background in `against`.
84
+ # Clamps at 1.0 (pure white/black), so a pathological base degrades to the
85
+ # best achievable ink instead of looping.
86
+ def contrast_ink(base, direction:, start:, target:, against:)
87
+ amount = start
88
+ loop do
89
+ ink = direction == :lighten ? ColorScale.lighten(base, amount) : ColorScale.darken(base, amount)
90
+ return ink if against.all? { |bg| ColorScale.contrast_ratio(ink, bg) >= target } || amount >= 1.0
91
+
92
+ amount = [amount + 0.02, 1.0].min
93
+ end
94
+ end
95
+
55
96
  # Generate --color-primary-{50..900} + RGB variants for Tailwind opacity support
56
97
  def primary_palette_vars
57
98
  primary = colors[:primary] || "#8E82FE"
@@ -83,8 +124,13 @@ module Studio
83
124
  "--color-inset" => ColorScale.darken(light_base, 0.08),
84
125
  "--color-text" => "#0f172a",
85
126
  "--color-text-body" => "#334155",
86
- "--color-text-secondary" => "#64748b",
87
- "--color-text-muted" => "#94a3b8",
127
+ # Same bounded search as dark mode: the old fixed grays measured as
128
+ # low as 2.05:1 (muted on --color-inset) — below the very defect this
129
+ # derivation exists to prevent. Ink darkens away from the light base.
130
+ "--color-text-secondary" => contrast_ink(light_base, direction: :darken, start: 0.55, target: 4.5,
131
+ against: light_surfaces(light_base)),
132
+ "--color-text-muted" => contrast_ink(light_base, direction: :darken, start: 0.40, target: 3.0,
133
+ against: light_surfaces(light_base)),
88
134
  "--color-border" => ColorScale.darken(light_base, 0.08),
89
135
  "--color-border-strong" => ColorScale.darken(light_base, 0.15),
90
136
  "--color-shadow" => "rgba(0,0,0,0.05)",
@@ -92,7 +138,8 @@ module Studio
92
138
  "--color-cta-hover" => ColorScale.darken(primary, 0.30),
93
139
  "--color-success" => colors[:success] || "#4BAF50",
94
140
  "--color-warning" => colors[:warning] || "#FF7C47",
95
- "--color-danger" => colors[:danger] || "#EF4444"
141
+ "--color-danger" => colors[:danger] || "#EF4444",
142
+ "--color-accent" => colors[:accent] || "#F72585"
96
143
  }
97
144
  end
98
145
  end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.30.0"
2
+ VERSION = "0.32.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -9,6 +9,7 @@ require "studio/username_generator"
9
9
  require "studio/s3"
10
10
  require "studio/image_cache"
11
11
  require "studio/link_token"
12
+ require "studio/link_resolution"
12
13
  require "studio/email"
13
14
  require "studio/email_smoke"
14
15
  require "studio/mail_transport"
@@ -80,18 +81,40 @@ module Studio
80
81
  # end
81
82
  mattr_accessor :sidebar_sections, default: []
82
83
 
83
- # Magic-link (passwordless email) tuning. token_name keys the MessageVerifier
84
- # purpose; bump it to invalidate every outstanding link. See MagicLink service.
85
- mattr_accessor :magic_link_ttl, default: 15.minutes
84
+ # How long a freshly minted magic link stays live.
85
+ mattr_accessor :magic_link_ttl, default: 15.minutes
86
+
87
+ # RETIRED (0.31.0) — kept only so an initializer that still sets it boots.
88
+ # It named the MessageVerifier purpose for the old :signed store, which no
89
+ # longer exists. Delete the line from your initializer.
86
90
  mattr_accessor :magic_link_token_name, default: "magic_link_v1"
87
91
 
88
- # Where magic-link tokens are stored / which URL scheme they use.
89
- # :signed (default) stateless MessageVerifier MagicLink service; URL is
90
- # /magic_link/<long token>. No table needed. Back-compat default.
91
- # :database a Studio::Link row; URL is the short /l/<token>. Requires the
92
- # studio_links table (install the reference migration). The
93
- # unified scheme both apps move to.
94
- mattr_accessor :magic_link_store, default: :signed
92
+ # RETIRED (0.31.0) — magic links are ALWAYS Studio::Link rows now, so this
93
+ # reads :database and nothing else. Assigning :signed raises rather than
94
+ # silently downgrading: that store minted a ~350-character MessageVerifier
95
+ # blob whose EXPIRED form cannot be decoded, so an app on it could not tell
96
+ # whose dead link it was holding — which is exactly the fact
97
+ # Studio::LinkResolution needs to leave a live session alone. Requires the
98
+ # studio_links table, installed by `bin/rails studio_engine:install:migrations`
99
+ # (never hand-copied — a hand copy collides with the task's own copy on
100
+ # `class CreateStudioLinks`).
101
+ mattr_reader :magic_link_store, default: :database
102
+
103
+ # `to_s.to_sym`, not `to_sym`: this runs from an initializer, and nil or an
104
+ # Integer would raise NoMethodError — swallowing the explanation below with a
105
+ # message that says nothing about what to do. A blank falls through to the
106
+ # raise instead, so the operator reads the actual instruction.
107
+ def self.magic_link_store=(value)
108
+ return if value.to_s.to_sym == :database
109
+
110
+ raise ArgumentError,
111
+ "Studio.magic_link_store = #{value.inspect} is retired (studio-engine 0.31.0). " \
112
+ "Magic links are Studio::Link rows served at /l/<token>. Delete this line from " \
113
+ "config/initializers/studio.rb, then install the table with " \
114
+ "`bin/rails studio_engine:install:migrations && bin/rails db:migrate` — in that " \
115
+ "order, because this raise fires while the initializer loads and no rake task can " \
116
+ "boot until the line is gone."
117
+ end
95
118
 
96
119
  # Whether Studio.routes draws the magic_link + solana wallet routes. An app that
97
120
  # already defines its own auth routes (e.g. turf-monster, which has battle-tested
@@ -229,13 +252,13 @@ module Studio
229
252
  false
230
253
  end
231
254
 
232
- # True when the emailed/inbox magic-link URL is the short /l/<token> — i.e.
233
- # magic links are Studio::Link rows AND this app draws the /l routes. False =
234
- # the legacy /magic_link/<token> path: the :signed store, OR an app on the
235
- # :database store that keeps its own /magic_link route (e.g. turf-monster,
236
- # whose /l is already its landing-page namespace).
255
+ # True when the emailed/inbox magic-link URL is the short /l/<token> — the
256
+ # standard. False means this app draws its own token route instead and owns
257
+ # the matching consume: turf-monster keeps /magic_link/<token> because /l is
258
+ # already its landing-page namespace. Either way the TOKEN is the same short
259
+ # Studio::Link token; only the path in front of it differs.
237
260
  def self.magic_link_via_l_route?
238
- magic_link_store == :database && draw_link_routes
261
+ draw_link_routes
239
262
  end
240
263
 
241
264
  # The floor every developer-desk tool sits on: the local email inbox
@@ -393,18 +416,15 @@ module Studio
393
416
  get "_studio/local_review", to: "studio/local_reviews#show", as: :studio_local_review
394
417
  end
395
418
 
396
- # Passwordless email (magic link). Helpers: magic_link_request_path (POST
397
- # to request a link), magic_link_path(token) / magic_link_url(token:)
398
- # for the emailed GET confirmation page, and magic_link_consume_path(token)
399
- # for the scanner-safe POST consume. The token is a URL-safe
400
- # MessageVerifier blob but the constraint guards against a stray "."
401
- # segment.
419
+ # Passwordless email (magic link) — the REQUEST half only. Helper:
420
+ # magic_link_request_path (POST an email address, get a link mailed).
421
+ #
422
+ # The token-bearing half moved to /l/<token> below (0.31.0). There is one
423
+ # token format now a short Studio::Link row and one place that burns
424
+ # it, so the old /magic_link/:token confirm+consume pair would have been a
425
+ # second door onto the same lock.
402
426
  if Studio.draw_auth_routes && Studio.auth_method?(:magic_link)
403
- post "magic_link", to: "magic_links#create", as: :magic_link_request
404
- get "magic_link/:token", to: "magic_links#confirm", as: :magic_link,
405
- constraints: { token: %r{[^/]+} }
406
- post "magic_link/:token", to: "magic_links#consume", as: :magic_link_consume,
407
- constraints: { token: %r{[^/]+} }
427
+ post "magic_link", to: "magic_links#create", as: :magic_link_request
408
428
  end
409
429
 
410
430
  # Unified short-token links — /l/<token> for magic sign-in links + referral