studio-engine 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 251aa22a7a41f33e6cab5a4f1f1c38a714aa4a14f2f4b08567c27340e1563f24
4
- data.tar.gz: 2f8b5ec4901d403c1ffdaf42dee430c69f1c31625c926e8deb43c021a41eb6a9
3
+ metadata.gz: 31ed2941026e5bf0114872ba16dd7a9b94b1a472d5407ff82ec604ba2e659962
4
+ data.tar.gz: 1fda4b1328315323b8afcee644a68acc8678e756762a88b289ea10e0fc46a6cf
5
5
  SHA512:
6
- metadata.gz: 1c8fa01470ce96a17550dac1d2d70db086b1acf0c6f6ed97a8b31679f2f3559dbb8549687bb79e806b2148a058ab700154bc330f4b94eb6b4216480fb0831d67
7
- data.tar.gz: 4ae572b5cadc0734b1ef69a8d0ec0d4331c57f3545be08e850dcd2b0d53c0f6d30d5e1b2d287a65542d46dec4b1d803cad6611471f24c533143118daa0fc75b4
6
+ metadata.gz: 6431e1dafbd0a614e1c0a6abe25004ab3888a3d1e7c263ab97d7883e10bcedde60da03f16c98b262d9926173749343b00c33d6a5580db9fc299c8b1b355a9665
7
+ data.tar.gz: 7513d2735c2535ed520530eab4adc0b1ce60de617488e4fa934e43beb31ef330e7e49c8837e3310aa92467559dbabf714fab43b81d86a0d52cebce8f85674e35
data/CHANGELOG.md CHANGED
@@ -4,6 +4,52 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.9.0 — 2026-06-23
8
+
9
+ ### Added
10
+ - **`Studio::Enumeral`** — a shared, DB-backed enumeration table for the whole
11
+ ecosystem. Every "list of fixed, labeled, colored values" (Pokémon types first;
12
+ statuses/tiers/roles later) is rows in ONE `studio_enumerals` table, grouped by
13
+ `category`: a stable machine `key`, a human `label`, a `color` hex, a display
14
+ `position`, a sparse `rank` (gappy 100/200/… domain ranking — e.g. most-common
15
+ first — distinct from display order), and `metadata` (jsonb) for
16
+ category-specific extras. Class helpers `catalog`, `lookup`, `color_map` (one
17
+ query → `{ key => color }`), `color_for(category, key, fallback:)`, and a
18
+ metadata-backed `emoji` reader + `emoji_map` (decorative glyphs live in
19
+ `metadata`, not a column); scopes `ordered` / `by_rank`; `available?` degrades
20
+ to empty when the table isn't installed. Shipped by the gem like `Studio::Link` /
21
+ `Studio::EmailDelivery` — reference migration `create_studio_enumerals`
22
+ installed per app (copy into `db/migrate`). No behavior is attached: a consumer
23
+ adopts a new category by seeding rows, no code change.
24
+
25
+ ## 0.8.0 — 2026-06-21
26
+
27
+ ### Added
28
+ - **`Studio::Link` + unified `/l/<token>`** — one short-token model + entry point
29
+ for both single-use, expiring **magic_link**s and reusable, non-expiring
30
+ **referral** links. Polymorphic `linkable` (optional), `metadata` jsonb (email/
31
+ return_to/target/age_attested off the wire), nullable `expires_at`, atomic
32
+ single-use consume. `Studio::LinkToken` holds the pure token/kind/sanitizer
33
+ logic (unit-tested without a DB); `Studio::LinksController` dispatches by kind
34
+ (magic → scanner-safe confirm + POST consume; referral → attribution cookie +
35
+ redirect). `Studio::LinkConsumption` concern shares sign-in/sign-up. Reference
36
+ migration `create_studio_links` (installed per app like `studio_email_deliveries`).
37
+ - **`Studio.magic_link_store`** (`:signed` default | `:database`) and
38
+ **`Studio.draw_link_routes`** flags. `:database` mints `Studio::Link`s and
39
+ emails the short `/l/<token>` (vs the legacy `/magic_link/<MessageVerifier>`);
40
+ fully back-compatible — the default leaves existing consumers untouched.
41
+ - **Branded mailer** lifted into the engine: `layouts/branded_mailer` + a branded
42
+ `UserMailer#magic_link` body (theme-colored button, banner-aware). Apps with
43
+ their own `UserMailer`/`branded_mailer` still win.
44
+ - **`Studio::EmailImage`** + **`/admin/email_images`** (admin-gated) — manage the
45
+ banner image on transactional emails (magic-link now; per-variant registry is
46
+ the extension point). Backed by `Studio::S3` + an owner-less `ImageCache` row
47
+ (permanent public URL, nil-safe pre-upload).
48
+
49
+ ### Changed
50
+ - `ImageCache#owner` is now optional (+ reference migration to drop the owner
51
+ `NOT NULL`), so app-global images (e.g. email banners) can be cached.
52
+
7
53
  ## v0.7.0 (2026-06-20)
8
54
 
9
55
  ### Added
@@ -0,0 +1,52 @@
1
+ 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.
7
+ #
8
+ # Relies on the host ApplicationController contract from Studio::ErrorHandling:
9
+ # set_app_session, rescue_and_log, current_user, plus root_path / login_path.
10
+ module LinkConsumption
11
+ extend ActiveSupport::Concern
12
+
13
+ private
14
+
15
+ def sign_in_existing(user, result)
16
+ set_app_session(user)
17
+ # Clicking the link proves email ownership, so verify any account that
18
+ # 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!")
21
+ end
22
+
23
+ # Build → configure_new_user → save!. No password — email auth is link-only.
24
+ def sign_up_new(result)
25
+ user = User.new(email: result.email)
26
+ Studio.configure_new_user.call(user)
27
+ rescue_and_log(target: user) do
28
+ user.save!
29
+ user.update!(email_verified_at: Time.current) if user.respond_to?(:email_verified_at)
30
+ set_app_session(user)
31
+ redirect_to(safe_path(result.return_to) || root_path, notice: Studio.welcome_message.call(user))
32
+ end
33
+ rescue ActiveRecord::RecordNotUnique
34
+ # Two valid tokens for the same brand-new email consumed near-simultaneously
35
+ # both miss find_by and race to save!; the loser hits the unique index.
36
+ # Benign — the account now exists, so just log the winner in.
37
+ existing = User.find_by(email: result.email)
38
+ return sign_in_existing(existing, result) if existing
39
+
40
+ redirect_to login_path, alert: "We couldn't finish creating your account. Please try again."
41
+ rescue StandardError => e
42
+ 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."
44
+ end
45
+
46
+ # Only same-origin absolute paths survive; everything else collapses to nil.
47
+ def safe_path(path)
48
+ p = path.to_s
49
+ p.start_with?("/") && !p.start_with?("//") ? p : nil
50
+ end
51
+ end
52
+ end
@@ -14,6 +14,8 @@
14
14
  # OVERRIDE this controller in the app and reuse the MagicLink service + the
15
15
  # sign_in_existing / sign_up_new building blocks.
16
16
  class MagicLinksController < ApplicationController
17
+ include Studio::LinkConsumption
18
+
17
19
  skip_before_action :require_authentication
18
20
  layout false, only: :confirm
19
21
 
@@ -23,7 +25,7 @@ class MagicLinksController < ApplicationController
23
25
  def create
24
26
  email = params[:email].to_s.strip.downcase
25
27
  if email.match?(URI::MailTo::EMAIL_REGEXP)
26
- token = MagicLink.generate(email: email, return_to: safe_path(params[:return_to]))
28
+ token = issue_magic_link(email, safe_path(params[:return_to]))
27
29
  Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
28
30
  end
29
31
  respond_to do |format|
@@ -57,38 +59,16 @@ class MagicLinksController < ApplicationController
57
59
 
58
60
  private
59
61
 
60
- def sign_in_existing(user, result)
61
- set_app_session(user)
62
- user.update!(email_verified_at: Time.current) if user.respond_to?(:email_verified_at) && user.email_verified_at.blank?
63
- redirect_to(safe_path(result.return_to) || root_path, notice: "Signed in. Welcome back!")
64
- end
65
-
66
- # Build configure_new_user → save!. There is no password — email auth is
67
- # magic-link only (the password_digest column, if present, stays dormant).
68
- def sign_up_new(result)
69
- user = User.new(email: result.email)
70
- Studio.configure_new_user.call(user)
71
- rescue_and_log(target: user) do
72
- user.save!
73
- user.update!(email_verified_at: Time.current) if user.respond_to?(:email_verified_at)
74
- set_app_session(user)
75
- redirect_to(safe_path(result.return_to) || root_path, notice: Studio.welcome_message.call(user))
62
+ # Mint a token in the configured store. Default :signed keeps the legacy
63
+ # stateless MessageVerifier link (URL: /magic_link/<token>); :database mints a
64
+ # Studio::Link row (URL: /l/<token>) the short, unified scheme. The mailer
65
+ # builds the matching URL (see UserMailer#magic_link). sign_in_existing /
66
+ # sign_up_new / safe_path come from Studio::LinkConsumption.
67
+ def issue_magic_link(email, return_to)
68
+ if Studio.magic_link_store == :database
69
+ Studio::Link.create_magic_link(email: email, return_to: return_to).token
70
+ else
71
+ MagicLink.generate(email: email, return_to: return_to)
76
72
  end
77
- rescue ActiveRecord::RecordNotUnique
78
- # Two valid tokens for the same brand-new email consumed near-simultaneously
79
- # both miss find_by and race to save!; the loser hits the unique index.
80
- # Benign — the account now exists, so just log the winner in.
81
- existing = User.find_by(email: result.email)
82
- return sign_in_existing(existing, result) if existing
83
-
84
- redirect_to login_path, alert: "We couldn't finish creating your account. Please try again."
85
- rescue StandardError => e
86
- Rails.logger.error("[MagicLinksController#consume] signup failed #{e.class}: #{e.message}")
87
- redirect_to login_path, alert: "We couldn't finish creating your account. Please try again."
88
- end
89
-
90
- def safe_path(path)
91
- p = path.to_s
92
- p.start_with?("/") && !p.start_with?("//") ? p : nil
93
73
  end
94
74
  end
@@ -0,0 +1,41 @@
1
+ module Studio
2
+ # Admin page to manage the banner image on transactional emails (Studio::
3
+ # EmailImage). One managed variant today (magic_link); the registry is the
4
+ # extension point. Shared by every app — surfaced from each app's admin hub.
5
+ class EmailImagesController < ApplicationController
6
+ before_action :require_admin
7
+
8
+ MAX_BYTES = 8.megabytes
9
+
10
+ def index
11
+ @variants = Studio::EmailImage.variants
12
+ end
13
+
14
+ # PATCH /admin/email_images/:variant — upload/replace a banner.
15
+ def update
16
+ variant = params[:variant].to_s
17
+ return head :not_found unless Studio::EmailImage.known?(variant)
18
+
19
+ file = params[:image]
20
+ unless valid_image?(file)
21
+ message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, or WebP under 8 MB."
22
+ return redirect_to admin_email_images_path, alert: message, status: :see_other
23
+ end
24
+
25
+ rescue_and_log do
26
+ Studio::EmailImage.store(variant, io: file, content_type: file.content_type)
27
+ redirect_to admin_email_images_path, notice: "#{Studio::EmailImage.label(variant)} banner updated."
28
+ end
29
+ rescue StandardError
30
+ redirect_to admin_email_images_path, alert: "Couldn't save the image. Please try again.", status: :see_other
31
+ end
32
+
33
+ private
34
+
35
+ def valid_image?(file)
36
+ file.respond_to?(:content_type) &&
37
+ file.content_type.to_s.start_with?("image/") &&
38
+ file.respond_to?(:size) && file.size.to_i.positive? && file.size <= MAX_BYTES
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,65 @@
1
+ module Studio
2
+ # The unified short-token link entry point — GET/POST /l/<token>. Dispatches by
3
+ # Studio::Link#kind:
4
+ #
5
+ # magic_link → scanner-safe confirm interstitial (GET, inert) that auto-POSTs
6
+ # to #consume, the ONLY place the single-use token is burned +
7
+ # the recipient is signed in / signed up.
8
+ # referral → idempotent: capture attribution into a cookie + redirect to
9
+ # the link's target (or root). Reusable + safe to prefetch, so
10
+ # GET does the work (no POST step).
11
+ #
12
+ # Namespaced (not top-level Links) because mcritchie-studio already owns a
13
+ # public /links linktree (top-level LinksController). Apps needing richer
14
+ # post-consume routing (contest landing, picks rehydration, age-gate) define
15
+ # their own Studio::LinksController and reuse Studio::Link + the
16
+ # Studio::LinkConsumption building blocks.
17
+ class LinksController < ApplicationController
18
+ include Studio::LinkConsumption
19
+
20
+ skip_before_action :require_authentication
21
+ layout false, only: :show
22
+
23
+ # GET /l/:token
24
+ def show
25
+ response.set_header("Referrer-Policy", "strict-origin")
26
+ @link = Studio::Link.find_by(token: params[:token])
27
+
28
+ case @link&.kind
29
+ when "magic_link"
30
+ @token = params[:token]
31
+ render :confirm
32
+ when "referral"
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."
37
+ end
38
+ end
39
+
40
+ # POST /l/:token — authoritative magic-link consume. Only magic_link kinds are
41
+ # consumable here; referral links are reusable and handled entirely on GET.
42
+ def consume
43
+ 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."
52
+ end
53
+
54
+ private
55
+
56
+ # Attribution rides in a cookie the app reads at signup (same :reference
57
+ # cookie the legacy ?ref= capture used). Value = the inviter's stable handle
58
+ # (slug) when available, else the link token. Capped to 64 chars.
59
+ def capture_referral(link)
60
+ inviter = link.linkable
61
+ ref = (inviter.respond_to?(:slug) && inviter.slug.presence) || link.token
62
+ cookies[:reference] = { value: ref.to_s.first(64), expires: 30.days, same_site: :lax }
63
+ end
64
+ end
65
+ end
@@ -84,7 +84,11 @@ module Studio
84
84
  path =
85
85
  case record.email_key
86
86
  when /#magic_link\z/
87
- "/magic_link/#{ERB::Util.url_encode(token)}"
87
+ # /l/<token> only when the app actually emails /l (Studio::Link store +
88
+ # /l routes drawn); otherwise the legacy /magic_link/<token> — covers the
89
+ # signed store AND apps like turf-monster that keep their own /magic_link.
90
+ base = Studio.magic_link_via_l_route? ? "/l" : "/magic_link"
91
+ "#{base}/#{ERB::Util.url_encode(token)}"
88
92
  when /#email_verification\z/
89
93
  "/email_verification/#{ERB::Util.url_encode(token)}"
90
94
  when /#wallet_export\z/
@@ -1,4 +1,8 @@
1
1
  class UserMailer < ApplicationMailer
2
+ # Branded shell (banner + card) for engine-sent UserMailer emails. An app with
3
+ # its own UserMailer + branded_mailer layout (e.g. turf-monster) overrides both.
4
+ layout "branded_mailer"
5
+
2
6
  # Passwordless sign-in link. `email` is a raw string (the recipient may not
3
7
  # have an account yet). Token is a signed MagicLink payload (email + return_to
4
8
  # + jti, single-use). Clicking the link logs the recipient in or creates their
@@ -7,8 +11,20 @@ class UserMailer < ApplicationMailer
7
11
  # Engine GENERIC base. An app needing richer copy (e.g. turf-monster's
8
12
  # contest-aware variant) defines its own UserMailer, which wins.
9
13
  def magic_link(email, token)
10
- @app_name = Studio.app_name
11
- @magic_url = magic_link_url(token: token)
14
+ @app_name = Studio.app_name
15
+ @email = email
16
+ @magic_url = magic_link_url_for(token)
17
+ @banner_url = Studio::EmailImage.url(:magic_link) # admin-managed; nil renders bannerless
18
+ @banner_alt = "Your #{@app_name} sign-in link"
12
19
  mail(to: email, subject: "Your #{@app_name} sign-in link")
13
20
  end
21
+
22
+ private
23
+
24
+ # Match the emailed URL to Studio.magic_link_store: the short /l/<token> for
25
+ # the :database scheme, the legacy /magic_link/<token> for :signed. The
26
+ # request side (MagicLinksController#issue_magic_link) mints the matching token.
27
+ def magic_link_url_for(token)
28
+ Studio.magic_link_via_l_route? ? link_url(token: token) : magic_link_url(token: token)
29
+ end
14
30
  end
@@ -1,5 +1,9 @@
1
1
  class ImageCache < ApplicationRecord
2
- belongs_to :owner, polymorphic: true
2
+ # Optional so app-GLOBAL images (no owning record) can be cached too — e.g.
3
+ # Studio::EmailImage stores the admin-managed email banners owner-less. Per-
4
+ # record images (athlete/coach headshots) still set an owner; the
5
+ # variant-uniqueness scope below keeps both shapes distinct.
6
+ belongs_to :owner, polymorphic: true, optional: true
3
7
 
4
8
  validates :purpose, :variant, :s3_key, presence: true
5
9
  validates :s3_key, uniqueness: true
@@ -0,0 +1,85 @@
1
+ module Studio
2
+ # A shared, DB-backed enumeration table for the whole ecosystem. Every "list of
3
+ # fixed, labeled, colored values" — Pokémon types (its first use), and later
4
+ # statuses, tiers, roles, … — is just rows in ONE studio_enumerals table,
5
+ # grouped by `category`. Each row is identity + presentation: a stable machine
6
+ # `key` ("fire"), a human `label` ("Fire"), a `color` hex ("#EE8130"), and a
7
+ # `position` for ordering within its category. `metadata` (jsonb) carries any
8
+ # category-specific extras off the columns.
9
+ #
10
+ # Shipped by the gem like Studio::Link / Studio::EmailDelivery: the model lives
11
+ # here, the table lives in each consumer app (copy the reference migration into
12
+ # db/migrate). No behavior is attached — it is pure reference data the apps
13
+ # read, so a consumer adopts a new category by seeding rows, no code change.
14
+ class Enumeral < ApplicationRecord
15
+ self.table_name = "studio_enumerals"
16
+
17
+ HEX = /\A#(?:\h{3}|\h{6})\z/
18
+
19
+ validates :category, presence: true
20
+ validates :key, presence: true,
21
+ uniqueness: { scope: :category, case_sensitive: false }
22
+ validates :color, format: { with: HEX, message: "must be a hex color like #EE8130" },
23
+ allow_blank: true
24
+
25
+ scope :in_category, ->(category) { where(category: category.to_s) }
26
+ scope :ordered, -> { order(:position, :key) }
27
+ # By `rank` (a sparse 100/200/… domain ranking), nulls last — for categories
28
+ # ordered by something other than display position, e.g. most-common-first.
29
+ scope :by_rank, -> { order(:rank) }
30
+
31
+ class << self
32
+ # True only when the table is actually migrated in this app's DB, so a
33
+ # consumer that hasn't installed the migration degrades to "empty" instead
34
+ # of crashing (mirrors Studio::EmailDelivery.available?).
35
+ def available?
36
+ connection.data_source_exists?(table_name)
37
+ rescue ActiveRecord::ActiveRecordError, NoMethodError
38
+ false
39
+ end
40
+
41
+ # The ordered list of a category's enumerals — the relation a view iterates
42
+ # (one query). Returns an empty relation when the table isn't installed yet,
43
+ # so callers can `.each` safely pre-migration.
44
+ def catalog(category)
45
+ return none unless available?
46
+
47
+ in_category(category).ordered
48
+ end
49
+
50
+ # The single enumeral for (category, key), or nil.
51
+ def lookup(category, key)
52
+ return nil unless available?
53
+
54
+ in_category(category).find_by(key: key.to_s)
55
+ end
56
+
57
+ # { key => color } for a category in ONE query — the shape a view wants:
58
+ # build it once, then look up each cell with no extra queries (avoids an
59
+ # N+1 when rendering many rows, e.g. the /pokemon type badges).
60
+ def color_map(category)
61
+ catalog(category).pluck(:key, :color).to_h
62
+ end
63
+
64
+ # Just the color for a single (category, key), with an optional fallback
65
+ # when the key is unknown or the table isn't installed yet.
66
+ def color_for(category, key, fallback: nil)
67
+ return fallback unless available?
68
+
69
+ in_category(category).where(key: key.to_s).limit(1).pick(:color) || fallback
70
+ end
71
+
72
+ # { key => emoji } for a category in ONE query — the metadata sibling of
73
+ # color_map, for decorative glyphs kept in `metadata` rather than a column.
74
+ def emoji_map(category)
75
+ catalog(category).pluck(:key, :metadata).to_h { |key, meta| [key, meta && meta["emoji"]] }
76
+ end
77
+ end
78
+
79
+ # A decorative glyph for the value, kept in `metadata` (not a column) since
80
+ # it's a presentation extra — e.g. a Pokémon type's emoji. Nil when unset.
81
+ def emoji
82
+ metadata && metadata["emoji"]
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,144 @@
1
+ module Studio
2
+ # One table, one /l/<token> entry point, for every short-token link the apps
3
+ # hand out: single-use, expiring **magic_link**s and reusable, non-expiring
4
+ # **referral** links. `kind` selects the behavior; `metadata` (jsonb) carries
5
+ # the kind-specific payload (email, return_to, target, age_attested) OFF the
6
+ # wire so the URL is just the short random token.
7
+ #
8
+ # Generalizes turf-monster's app-local MagicLink model: adds a polymorphic
9
+ # `linkable` owner (the inviting User for referrals; left nil for a magic link
10
+ # to a not-yet-existent email — that email rides in metadata) and the `kind`
11
+ # discriminator. Replaces the engine's stateless MessageVerifier MagicLink
12
+ # service for mcritchie-studio so both apps share one short-token scheme.
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.
16
+ class Link < ApplicationRecord
17
+ self.table_name = "studio_links"
18
+
19
+ class InvalidToken < StandardError; end
20
+
21
+ belongs_to :linkable, polymorphic: true, optional: true
22
+
23
+ validates :kind, inclusion: { in: Studio::LinkToken::KINDS }
24
+ validates :token, presence: true, uniqueness: true
25
+
26
+ scope :magic_links, -> { where(kind: "magic_link") }
27
+ scope :referrals, -> { where(kind: "referral") }
28
+ scope :unconsumed, -> { where(consumed_at: nil) }
29
+ scope :live, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
30
+
31
+ class << self
32
+ # --- minting -----------------------------------------------------------
33
+
34
+ # A single-use sign-in/sign-up link. The email rides in metadata (not the
35
+ # URL, not a column) per the create-or-login flow — the account may not
36
+ # exist yet. `ttl` defaults to the app's Studio.magic_link_ttl.
37
+ def create_magic_link(email:, return_to: nil, age_attested: false, linkable: nil, ttl: nil)
38
+ ttl ||= Studio.magic_link_ttl
39
+ mint!(
40
+ kind: "magic_link",
41
+ linkable: linkable,
42
+ expires_at: ttl.from_now,
43
+ metadata: {
44
+ "email" => Studio::LinkToken.normalize_email(email),
45
+ "return_to" => Studio::LinkToken.sanitize_path(return_to),
46
+ "age_attested" => !!age_attested
47
+ }.compact
48
+ )
49
+ end
50
+
51
+ # A user's referral link is stable + reusable, keyed by its landing target
52
+ # so sharing contest A vs B yields distinct (but each stable) links — both
53
+ # crediting the same inviter. `target` is an optional same-origin path the
54
+ # referral redirects to (e.g. a specific contest).
55
+ def referral_for(linkable, target: nil)
56
+ wanted = Studio::LinkToken.sanitize_path(target)
57
+ referrals.where(linkable: linkable).live.detect { |link| link.target == wanted } ||
58
+ mint!(
59
+ kind: "referral",
60
+ linkable: linkable,
61
+ expires_at: nil,
62
+ metadata: { "target" => wanted }.compact
63
+ )
64
+ end
65
+
66
+ # Find by token + burn-if-single-use in one call. Raises InvalidToken for
67
+ # unknown / expired / already-used links. Returns the live Link.
68
+ def consume!(token)
69
+ link = find_by(token: token.to_s)
70
+ raise InvalidToken, "unknown link" unless link
71
+
72
+ link.consume!
73
+ end
74
+
75
+ private
76
+
77
+ # create! with a fresh random token, retrying the (astronomically rare)
78
+ # unique-index collision a couple of times before surfacing the error.
79
+ def mint!(attrs)
80
+ 3.times do
81
+ return create!(attrs.merge(token: Studio::LinkToken.generate))
82
+ rescue ActiveRecord::RecordNotUnique
83
+ next
84
+ end
85
+ create!(attrs.merge(token: Studio::LinkToken.generate))
86
+ end
87
+ end
88
+
89
+ # --- consumption ---------------------------------------------------------
90
+
91
+ # Single-use kinds (magic_link) are atomically burned: only the first caller
92
+ # flips consumed_at, so a replay / double-submit loses the race and is
93
+ # rejected. Reusable kinds (referral) only check expiry. Returns self.
94
+ def consume!
95
+ if single_use?
96
+ burned = self.class.unconsumed
97
+ .where(id: id)
98
+ .where("expires_at IS NULL OR expires_at > ?", Time.current)
99
+ .update_all(consumed_at: Time.current)
100
+ raise InvalidToken, "link already used or expired" if burned.zero?
101
+
102
+ self.consumed_at = Time.current
103
+ elsif expired?
104
+ raise InvalidToken, "link expired"
105
+ end
106
+ self
107
+ end
108
+
109
+ def single_use?
110
+ Studio::LinkToken.single_use?(kind)
111
+ end
112
+
113
+ def expired?
114
+ expires_at.present? && expires_at <= Time.current
115
+ end
116
+
117
+ def consumed?
118
+ consumed_at.present?
119
+ end
120
+
121
+ def live?
122
+ !expired? && !(single_use? && consumed?)
123
+ end
124
+
125
+ # --- metadata readers (sanitized on the way out) -------------------------
126
+
127
+ def email
128
+ metadata && metadata["email"]
129
+ end
130
+
131
+ def return_to
132
+ Studio::LinkToken.sanitize_path(metadata && metadata["return_to"])
133
+ end
134
+
135
+ def target
136
+ Studio::LinkToken.sanitize_path(metadata && metadata["target"])
137
+ end
138
+
139
+ def age_attested
140
+ !!(metadata && metadata["age_attested"])
141
+ end
142
+ alias_method :age_attested?, :age_attested
143
+ end
144
+ end
@@ -0,0 +1,85 @@
1
+ module Studio
2
+ # Admin-managed banner images for transactional emails. One image per "variant"
3
+ # (the email type), uploaded to S3 with a stable PUBLIC url via Studio::S3 and
4
+ # tracked by an owner-less ImageCache row (purpose "email_banner"). The branded
5
+ # mailer resolves the current banner with .url; the admin email-image page
6
+ # writes it with .store.
7
+ #
8
+ # VARIANTS is the registry — magic_link now; adding an entry is all it takes to
9
+ # admin-manage another email's header image (the "extensible" part of the
10
+ # magic-link-now-extensible scope).
11
+ module EmailImage
12
+ PURPOSE = "email_banner".freeze
13
+
14
+ # variant => human label, in admin display order.
15
+ VARIANTS = {
16
+ "magic_link" => "Magic-link sign-in"
17
+ }.freeze
18
+
19
+ module_function
20
+
21
+ def variants
22
+ VARIANTS
23
+ end
24
+
25
+ def label(variant)
26
+ VARIANTS[variant.to_s] || variant.to_s.humanize
27
+ end
28
+
29
+ def known?(variant)
30
+ VARIANTS.key?(variant.to_s)
31
+ end
32
+
33
+ # The ImageCache row for this variant, or nil (no banner uploaded / table not
34
+ # installed yet). Nil-safe so the mailer renders bannerless before any upload.
35
+ def record(variant)
36
+ return nil unless table_ready?
37
+
38
+ ::ImageCache.find_by(owner: nil, purpose: PURPOSE, variant: variant.to_s)
39
+ end
40
+
41
+ # Permanent public S3 url for the current banner, or nil.
42
+ def url(variant)
43
+ record(variant)&.url
44
+ end
45
+
46
+ # Upload bytes to S3 + upsert the ImageCache row (replacing any prior object).
47
+ # Returns the ::ImageCache. Raises on failure after cleaning up the new object.
48
+ def store(variant, io:, content_type: nil)
49
+ key = "email_banners/#{variant}-#{SecureRandom.hex(4)}#{ext_for(content_type)}"
50
+ Studio::S3.upload(key: key, body: io.read, content_type: content_type,
51
+ cache_control: "public, max-age=300")
52
+ record = ::ImageCache.find_or_initialize_by(owner: nil, purpose: PURPOSE, variant: variant.to_s)
53
+ previous = record.s3_key
54
+ record.update!(s3_key: key)
55
+ delete_object(previous) if previous.present? && previous != key
56
+ record
57
+ rescue StandardError
58
+ delete_object(key)
59
+ raise
60
+ end
61
+
62
+ # Reference ImageCache directly so Zeitwerk autoloads it — defined?() does NOT
63
+ # trigger autoload, so it would read "undefined" for a not-yet-loaded const.
64
+ def table_ready?
65
+ ::ImageCache.table_exists?
66
+ rescue NameError, ActiveRecord::ActiveRecordError
67
+ false
68
+ end
69
+
70
+ def ext_for(content_type)
71
+ case content_type.to_s
72
+ when %r{png} then ".png"
73
+ when %r{jpe?g} then ".jpg"
74
+ when %r{webp} then ".webp"
75
+ else ".png"
76
+ end
77
+ end
78
+
79
+ def delete_object(key)
80
+ Studio::S3.delete(key: key)
81
+ rescue StandardError
82
+ nil
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,31 @@
1
+ <%#
2
+ Shared branded transactional email shell. A full-bleed banner (set @banner_url,
3
+ e.g. from Studio::EmailImage.url(:magic_link)) sits flush at the top and sets
4
+ the 600px width; each email view supplies the body via yield. Bannerless is
5
+ fine — the card still renders. Lifted from turf-monster so every Studio app
6
+ shares one branded look. An app can override by defining its own
7
+ layouts/branded_mailer.html.erb.
8
+ %>
9
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0;padding:24px 0;background:#f4f5f7;">
10
+ <tr>
11
+ <td align="center" style="padding:0 12px;">
12
+ <table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 1px 4px rgba(15,23,42,0.08);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
13
+
14
+ <% if @banner_url.present? %>
15
+ <tr>
16
+ <td style="padding:0;line-height:0;">
17
+ <img src="<%= @banner_url %>" width="600" alt="<%= @banner_alt || Studio.app_name %>"
18
+ style="display:block;width:100%;max-width:600px;height:auto;border:0;" />
19
+ </td>
20
+ </tr>
21
+ <% end %>
22
+
23
+ <tr>
24
+ <td style="padding:32px 36px 36px;color:#2f3a2c;">
25
+ <%= yield %>
26
+ </td>
27
+ </tr>
28
+ </table>
29
+ </td>
30
+ </tr>
31
+ </table>
@@ -1,96 +1,2 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width,initial-scale=1">
6
- <%= csrf_meta_tags %>
7
- <title>Signing you in - <%= Studio.app_name %></title>
8
- <style>
9
- @keyframes magic-spin { to { transform: rotate(360deg); } }
10
-
11
- * { box-sizing: border-box; }
12
-
13
- body {
14
- margin: 0;
15
- min-height: 100vh;
16
- display: grid;
17
- place-items: center;
18
- color: #f8fafc;
19
- background: <%= Studio.theme_dark %>;
20
- font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
21
- }
22
-
23
- main { width: min(100% - 32px, 420px); text-align: center; }
24
-
25
- .magic-spinner {
26
- width: 88px;
27
- height: 88px;
28
- margin: 0 auto;
29
- border-radius: 9999px;
30
- border: 8px solid rgba(255, 255, 255, 0.14);
31
- border-top-color: <%= Studio.theme_success %>;
32
- animation: magic-spin 0.8s linear infinite;
33
- }
34
-
35
- .magic-fallback {
36
- display: none;
37
- margin-top: 2rem;
38
- color: rgba(248, 250, 252, 0.8);
39
- }
40
-
41
- .magic-fallback p { margin: 0 0 1rem; font-size: 15px; }
42
-
43
- .magic-submit {
44
- display: inline-block;
45
- max-width: 100%;
46
- padding: 14px 34px;
47
- border: 0;
48
- border-radius: 8px;
49
- color: #ffffff;
50
- background: <%= Studio.theme_success %>;
51
- font: inherit;
52
- font-size: 16px;
53
- font-weight: 700;
54
- cursor: pointer;
55
- }
56
- </style>
57
- </head>
58
- <body>
59
- <main>
60
- <div class="magic-spinner" role="status" aria-label="Signing you in"></div>
61
-
62
- <div id="magic-fallback" class="magic-fallback">
63
- <p>Taking longer than expected?</p>
64
- <%= button_to magic_link_consume_path(token: @token),
65
- method: :post,
66
- form: { id: "magic-consume-form", data: { turbo: "false" } },
67
- class: "magic-submit" do %>
68
- Sign in to <%= Studio.app_name %>
69
- <% end %>
70
- </div>
71
- </main>
72
-
73
- <noscript>
74
- <style>
75
- #magic-fallback { display: block !important; }
76
- .magic-spinner { display: none; }
77
- </style>
78
- </noscript>
79
-
80
- <script>
81
- (function () {
82
- var form = document.getElementById("magic-consume-form");
83
- if (form && !form.dataset.autoSubmitted) {
84
- form.dataset.autoSubmitted = "1";
85
- if (typeof form.requestSubmit === "function") form.requestSubmit();
86
- else form.submit();
87
- }
88
-
89
- setTimeout(function () {
90
- var fallback = document.getElementById("magic-fallback");
91
- if (fallback) fallback.style.display = "block";
92
- }, 4000);
93
- })();
94
- </script>
95
- </body>
96
- </html>
1
+ <%# Legacy /magic_link/:token interstitial. Shared body in studio/_confirm_interstitial. %>
2
+ <%= render "studio/confirm_interstitial", consume_path: magic_link_consume_path(token: @token) %>
@@ -0,0 +1,105 @@
1
+ <%#
2
+ Shared scanner-safe sign-in interstitial. The GET that renders this is inert;
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).
7
+
8
+ Local: consume_path — the POST target that burns the token + signs in.
9
+ %>
10
+ <!DOCTYPE html>
11
+ <html lang="en">
12
+ <head>
13
+ <meta charset="utf-8">
14
+ <meta name="viewport" content="width=device-width,initial-scale=1">
15
+ <%= csrf_meta_tags %>
16
+ <title>Signing you in - <%= Studio.app_name %></title>
17
+ <style>
18
+ @keyframes magic-spin { to { transform: rotate(360deg); } }
19
+
20
+ * { box-sizing: border-box; }
21
+
22
+ body {
23
+ margin: 0;
24
+ min-height: 100vh;
25
+ display: grid;
26
+ place-items: center;
27
+ color: #f8fafc;
28
+ background: <%= Studio.theme_dark %>;
29
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
30
+ }
31
+
32
+ main { width: min(100% - 32px, 420px); text-align: center; }
33
+
34
+ .magic-spinner {
35
+ width: 88px;
36
+ height: 88px;
37
+ margin: 0 auto;
38
+ border-radius: 9999px;
39
+ border: 8px solid rgba(255, 255, 255, 0.14);
40
+ border-top-color: <%= Studio.theme_success %>;
41
+ animation: magic-spin 0.8s linear infinite;
42
+ }
43
+
44
+ .magic-fallback {
45
+ display: none;
46
+ margin-top: 2rem;
47
+ color: rgba(248, 250, 252, 0.8);
48
+ }
49
+
50
+ .magic-fallback p { margin: 0 0 1rem; font-size: 15px; }
51
+
52
+ .magic-submit {
53
+ display: inline-block;
54
+ max-width: 100%;
55
+ padding: 14px 34px;
56
+ border: 0;
57
+ border-radius: 8px;
58
+ color: #ffffff;
59
+ background: <%= Studio.theme_success %>;
60
+ font: inherit;
61
+ font-size: 16px;
62
+ font-weight: 700;
63
+ cursor: pointer;
64
+ }
65
+ </style>
66
+ </head>
67
+ <body>
68
+ <main>
69
+ <div class="magic-spinner" role="status" aria-label="Signing you in"></div>
70
+
71
+ <div id="magic-fallback" class="magic-fallback">
72
+ <p>Taking longer than expected?</p>
73
+ <%= button_to consume_path,
74
+ method: :post,
75
+ form: { id: "magic-consume-form", data: { turbo: "false" } },
76
+ class: "magic-submit" do %>
77
+ Sign in to <%= Studio.app_name %>
78
+ <% end %>
79
+ </div>
80
+ </main>
81
+
82
+ <noscript>
83
+ <style>
84
+ #magic-fallback { display: block !important; }
85
+ .magic-spinner { display: none; }
86
+ </style>
87
+ </noscript>
88
+
89
+ <script>
90
+ (function () {
91
+ var form = document.getElementById("magic-consume-form");
92
+ if (form && !form.dataset.autoSubmitted) {
93
+ form.dataset.autoSubmitted = "1";
94
+ if (typeof form.requestSubmit === "function") form.requestSubmit();
95
+ else form.submit();
96
+ }
97
+
98
+ setTimeout(function () {
99
+ var fallback = document.getElementById("magic-fallback");
100
+ if (fallback) fallback.style.display = "block";
101
+ }, 4000);
102
+ })();
103
+ </script>
104
+ </body>
105
+ </html>
@@ -0,0 +1,41 @@
1
+ <% content_for(:title) { "Email images" } %>
2
+ <div class="max-w-2xl mx-auto px-4 py-8">
3
+ <header class="mb-6">
4
+ <h1 class="text-2xl font-bold">Email images</h1>
5
+ <p class="text-sm text-muted mt-1">
6
+ The banner shown at the top of branded transactional emails. Recommended
7
+ width 600px (it's displayed full-bleed at the top of the email card).
8
+ </p>
9
+ </header>
10
+
11
+ <% flash.each do |type, message| %>
12
+ <div class="mb-4 rounded-lg px-4 py-3 text-sm <%= type.to_s == "alert" ? "bg-danger/10 text-danger" : "bg-success/10 text-success" %>">
13
+ <%= message %>
14
+ </div>
15
+ <% end %>
16
+
17
+ <div class="space-y-6">
18
+ <% @variants.each do |variant, label| %>
19
+ <% current_url = Studio::EmailImage.url(variant) %>
20
+ <section class="rounded-xl border border-subtle p-5">
21
+ <h2 class="font-semibold mb-3"><%= label %></h2>
22
+
23
+ <div class="rounded-lg overflow-hidden border border-subtle mb-3" style="aspect-ratio: 600 / 240; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
24
+ <% if current_url %>
25
+ <%= image_tag current_url, class: "w-full h-full object-cover", alt: "#{label} email banner" %>
26
+ <% else %>
27
+ <div class="w-full h-full flex items-center justify-center text-xs text-white/80 text-center px-4">
28
+ No image yet — emails send without a banner until you upload one.
29
+ </div>
30
+ <% end %>
31
+ </div>
32
+
33
+ <%= form_with url: admin_email_image_path(variant), method: :patch, html: { multipart: true }, class: "flex flex-wrap items-center gap-3" do %>
34
+ <%= file_field_tag :image, accept: "image/png,image/jpeg,image/webp",
35
+ class: "text-sm file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-white file:cursor-pointer" %>
36
+ <%= submit_tag(current_url ? "Replace image" : "Upload image", class: "btn btn-primary btn-sm") %>
37
+ <% end %>
38
+ </section>
39
+ <% end %>
40
+ </div>
41
+ </div>
@@ -0,0 +1,2 @@
1
+ <%# /l/:token magic-link interstitial. Shared body in studio/_confirm_interstitial. %>
2
+ <%= render "studio/confirm_interstitial", consume_path: link_consume_path(token: @token) %>
@@ -1,14 +1,29 @@
1
- <h1>Your sign-in link</h1>
1
+ <%# Body only — branded_mailer supplies the banner + card. Uses the theme success
2
+ color for the button so it matches each app. %>
3
+ <h1 style="margin:0 0 18px;font-size:22px;line-height:1.3;color:#1f2a1c;">Your sign-in link</h1>
2
4
 
3
- <p>Tap the button below to sign in to <%= @app_name %>. If you don't have an
4
- account yet, we'll create one for you — no password needed:</p>
5
+ <p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;">
6
+ Tap the button below to sign in to <strong style="color:#1f2a1c;"><%= @app_name %></strong> — no password needed.
7
+ If you don't have an account yet, we'll create one for you.
8
+ </p>
5
9
 
6
- <p><%= link_to "Sign in to #{@app_name}", @magic_url %></p>
10
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto;">
11
+ <tr><td align="center" bgcolor="<%= Studio.theme_success %>" style="border-radius:10px;">
12
+ <a href="<%= @magic_url %>" style="display:inline-block;padding:16px 40px;font-size:17px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:10px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">Sign in to <%= @app_name %> 🪄</a>
13
+ </td></tr>
14
+ </table>
7
15
 
8
- <p>If the button doesn't work, copy and paste this URL into your browser:</p>
9
- <p><%= @magic_url %></p>
16
+ <p style="margin:28px 0 0;font-size:13px;line-height:1.5;color:#6b756a;text-align:center;">
17
+ This link is for <strong style="color:#2f3a2c;"><%= @email %></strong> and expires in <%= Studio.magic_link_ttl.in_minutes.round %> minutes — it can only be used once.
18
+ </p>
10
19
 
11
- <p>Open this link on this device to finish. It expires shortly and can only be
12
- used once. If you didn't request it, you can safely ignore this message.</p>
20
+ <p style="margin:24px 0 0;font-size:12px;line-height:1.4;color:#8a948a;text-align:center;">
21
+ Button not working? Copy and paste this link:<br>
22
+ <a href="<%= @magic_url %>" style="font-size:9px;line-height:1.3;color:<%= Studio.theme_success %>;word-break:break-all;"><%= @magic_url %></a>
23
+ </p>
13
24
 
14
- <p>— <%= @app_name %></p>
25
+ <p style="margin:28px 0 0;font-size:12px;line-height:1.5;color:#8a948a;text-align:center;">
26
+ Didn't request this? You can safely ignore this email.
27
+ </p>
28
+
29
+ <p style="margin:20px 0 0;font-size:13px;color:#6b756a;text-align:center;">— <%= @app_name %></p>
@@ -0,0 +1,27 @@
1
+ # Reference migration for the Studio::Link model. Like the engine's
2
+ # studio_email_deliveries migration, each consumer app installs its own copy of
3
+ # this into db/migrate (the adoption tasks do that) so the table is created in
4
+ # the app's database.
5
+ class CreateStudioLinks < ActiveRecord::Migration[7.2]
6
+ def change
7
+ create_table :studio_links do |t|
8
+ t.string :token, null: false
9
+ t.string :kind, null: false
10
+ # Polymorphic owner — the inviting User for referrals; nil for a magic
11
+ # link to a not-yet-existent email (that email rides in metadata).
12
+ t.references :linkable, polymorphic: true, index: false
13
+ t.jsonb :metadata, null: false, default: {}
14
+ t.datetime :expires_at
15
+ t.datetime :consumed_at
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :studio_links, :token, unique: true
21
+ add_index :studio_links, :kind
22
+ # Covers both "this owner's links" and "this owner's referral" lookups
23
+ # (referral_for) via the leading columns.
24
+ add_index :studio_links, [:linkable_type, :linkable_id, :kind],
25
+ name: "idx_studio_links_owner_kind"
26
+ end
27
+ end
@@ -0,0 +1,9 @@
1
+ # Lets ImageCache cache app-GLOBAL images (no owning record) — e.g. Studio::
2
+ # EmailImage stores the admin-managed email banners owner-less. Reference
3
+ # migration; each consumer app installs its own copy (the table is app-owned).
4
+ class AllowNullImageCacheOwner < ActiveRecord::Migration[7.2]
5
+ def change
6
+ change_column_null :image_caches, :owner_type, true
7
+ change_column_null :image_caches, :owner_id, true
8
+ end
9
+ end
@@ -0,0 +1,30 @@
1
+ # Reference migration for the Studio::Enumeral model. Like the engine's
2
+ # studio_links / studio_email_deliveries migrations, each consumer app installs
3
+ # its own copy of this into db/migrate so the table is created in the app's
4
+ # database (the model is shipped by the gem; the table lives per app).
5
+ class CreateStudioEnumerals < ActiveRecord::Migration[7.2]
6
+ def change
7
+ create_table :studio_enumerals do |t|
8
+ # `category` groups a fixed set of values (e.g. "pokemon_type"); `key` is
9
+ # the stable machine value within it (e.g. "fire"). label/color/position
10
+ # are presentation; metadata (jsonb) carries any category-specific extras.
11
+ t.string :category, null: false
12
+ t.string :key, null: false
13
+ t.string :label
14
+ t.string :color
15
+ t.integer :position, null: false, default: 0
16
+ # A sparse, gappy ordinal (100, 200, …) for a domain ranking distinct from
17
+ # display `position` — e.g. Pokémon types ranked most-common → least-common.
18
+ # The gaps leave room to insert a value between two ranks without
19
+ # renumbering. Nullable: not every enumeral is ranked.
20
+ t.integer :rank
21
+ t.jsonb :metadata, null: false, default: {}
22
+
23
+ t.timestamps
24
+ end
25
+
26
+ add_index :studio_enumerals, [:category, :key], unique: true
27
+ add_index :studio_enumerals, [:category, :position]
28
+ add_index :studio_enumerals, [:category, :rank]
29
+ end
30
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Studio
6
+ # Pure-Ruby helpers behind the Studio::Link model — token minting, the kind
7
+ # rules, and the input sanitizers. Kept free of ActiveRecord so it loads (and
8
+ # unit-tests) without a database, mirroring the other lib/studio/*.rb pure
9
+ # classes. The AR model (app/models/studio/link.rb) delegates to this.
10
+ module LinkToken
11
+ # The kinds of link that share the studio_links table + the /l/<token>
12
+ # entry point.
13
+ # magic_link — single-use, short-lived passwordless sign-in / sign-up
14
+ # referral — reusable, non-expiring share link owned by a User
15
+ KINDS = %w[magic_link referral].freeze
16
+
17
+ # Kinds burned on first successful consume. Referral links are reusable, so
18
+ # they are deliberately NOT single-use.
19
+ SINGLE_USE_KINDS = %w[magic_link].freeze
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
25
+
26
+ module_function
27
+
28
+ # A fresh URL-safe token. urlsafe_base64 emits only [A-Za-z0-9_-], so the
29
+ # token satisfies the %r{[^/]+} route constraint and survives URL generation
30
+ # without extra encoding.
31
+ def generate
32
+ SecureRandom.urlsafe_base64(TOKEN_BYTES)
33
+ end
34
+
35
+ def kind?(kind)
36
+ KINDS.include?(kind.to_s)
37
+ end
38
+
39
+ def single_use?(kind)
40
+ SINGLE_USE_KINDS.include?(kind.to_s)
41
+ end
42
+
43
+ def normalize_email(email)
44
+ email.to_s.strip.downcase
45
+ end
46
+
47
+ # Only same-origin absolute paths survive; protocol-relative ("//evil"),
48
+ # absolute URLs, and blanks collapse to nil so callers fall back to a safe
49
+ # default redirect. Mirrors the MagicLink service's sanitizer.
50
+ def sanitize_path(path)
51
+ p = path.to_s
52
+ p.start_with?("/") && !p.start_with?("//") ? p : nil
53
+ end
54
+ end
55
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.7.0"
2
+ VERSION = "0.9.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -6,6 +6,7 @@ require "studio/ui_primitives"
6
6
  require "studio/username_generator"
7
7
  require "studio/s3"
8
8
  require "studio/image_cache"
9
+ require "studio/link_token"
9
10
  require "studio/email"
10
11
  require "studio/email_smoke"
11
12
  require "studio/mail_transport"
@@ -35,12 +36,26 @@ module Studio
35
36
  mattr_accessor :magic_link_ttl, default: 15.minutes
36
37
  mattr_accessor :magic_link_token_name, default: "magic_link_v1"
37
38
 
39
+ # Where magic-link tokens are stored / which URL scheme they use.
40
+ # :signed (default) — stateless MessageVerifier MagicLink service; URL is
41
+ # /magic_link/<long token>. No table needed. Back-compat default.
42
+ # :database — a Studio::Link row; URL is the short /l/<token>. Requires the
43
+ # studio_links table (install the reference migration). The
44
+ # unified scheme both apps move to.
45
+ mattr_accessor :magic_link_store, default: :signed
46
+
38
47
  # Whether Studio.routes draws the magic_link + solana wallet routes. An app that
39
48
  # already defines its own auth routes (e.g. turf-monster, which has battle-tested
40
49
  # magic_link/solana routes + extras) sets this false to avoid duplicate route
41
50
  # NAMES at boot, keeping its own routes intact. New consumers leave it true.
42
51
  mattr_accessor :draw_auth_routes, default: true
43
52
 
53
+ # Whether Studio.routes draws the unified /l/<token> link routes
54
+ # (Studio::LinksController — magic-link confirm/consume + referral redirect).
55
+ # Default true; an app wanting its own /l handling sets this false and draws
56
+ # its own routes (it can still reuse Studio::Link + Studio::LinkConsumption).
57
+ mattr_accessor :draw_link_routes, default: true
58
+
44
59
  # Optional admin Act As / impersonation session conventions. Consumers that
45
60
  # include Studio::Impersonation get current_user layered over true_user with
46
61
  # these session keys, but still own authorization, audit logging, and routes.
@@ -135,6 +150,15 @@ module Studio
135
150
  auth_methods.include?(method.to_sym)
136
151
  end
137
152
 
153
+ # True when the emailed/inbox magic-link URL is the short /l/<token> — i.e.
154
+ # magic links are Studio::Link rows AND this app draws the /l routes. False =
155
+ # the legacy /magic_link/<token> path: the :signed store, OR an app on the
156
+ # :database store that keeps its own /magic_link route (e.g. turf-monster,
157
+ # whose /l is already its landing-page namespace).
158
+ def self.magic_link_via_l_route?
159
+ magic_link_store == :database && draw_link_routes
160
+ end
161
+
138
162
  def self.local_email_capture?
139
163
  return false if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
140
164
  return !!local_email_capture unless local_email_capture.nil?
@@ -247,6 +271,19 @@ module Studio
247
271
  constraints: { token: %r{[^/]+} }
248
272
  end
249
273
 
274
+ # Unified short-token links — /l/<token> for magic sign-in links + referral
275
+ # links (Studio::Link). Studio::LinksController dispatches by kind: a
276
+ # magic_link renders the scanner-safe confirm interstitial then POSTs to
277
+ # consume; a referral captures attribution + redirects. Helpers: link_path
278
+ # / link_url(token:) and link_consume_path. Drawn for every consumer
279
+ # (including draw_auth_routes=false apps) unless draw_link_routes is off.
280
+ if Studio.draw_link_routes
281
+ get "l/:token", to: "studio/links#show", as: :link,
282
+ constraints: { token: %r{[^/]+} }
283
+ post "l/:token", to: "studio/links#consume", as: :link_consume,
284
+ constraints: { token: %r{[^/]+} }
285
+ end
286
+
250
287
  # Solana / Phantom wallet sign-in (nonce challenge + signature verify).
251
288
  # The browser posts to these literal paths from the shared Connect-Wallet
252
289
  # flow; app-specific surfaces (mobile deep-link callback, account-linking,
@@ -263,6 +300,13 @@ module Studio
263
300
  patch "admin/theme", to: "theme_settings#update", as: :admin_theme_update
264
301
  post "admin/theme/regenerate", to: "theme_settings#regenerate", as: :admin_theme_regenerate
265
302
  get "admin/schema", to: "schema#index", as: :admin_schema
303
+
304
+ # Admin-managed transactional-email banner images (Studio::EmailImage).
305
+ # index lists each managed email variant + its current banner; update
306
+ # uploads a replacement. Surfaced from each app's admin hub.
307
+ get "admin/email_images", to: "studio/email_images#index", as: :admin_email_images
308
+ patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
309
+ constraints: { variant: /[a-z_]+/ }
266
310
  end
267
311
  end
268
312
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-20 00:00:00.000000000 Z
11
+ date: 2026-06-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -148,6 +148,7 @@ files:
148
148
  - app/controllers/concerns/studio/admin_models.rb
149
149
  - app/controllers/concerns/studio/error_handling.rb
150
150
  - app/controllers/concerns/studio/impersonation.rb
151
+ - app/controllers/concerns/studio/link_consumption.rb
151
152
  - app/controllers/error_logs_controller.rb
152
153
  - app/controllers/magic_links_controller.rb
153
154
  - app/controllers/navbar_controller.rb
@@ -156,6 +157,8 @@ files:
156
157
  - app/controllers/schema_controller.rb
157
158
  - app/controllers/sessions_controller.rb
158
159
  - app/controllers/solana_sessions_controller.rb
160
+ - app/controllers/studio/email_images_controller.rb
161
+ - app/controllers/studio/links_controller.rb
159
162
  - app/controllers/studio/local_emails_controller.rb
160
163
  - app/controllers/theme_settings_controller.rb
161
164
  - app/helpers/studio/admin_models_table_helper.rb
@@ -171,9 +174,12 @@ files:
171
174
  - app/models/image_cache.rb
172
175
  - app/models/session_context.rb
173
176
  - app/models/studio/email_delivery.rb
177
+ - app/models/studio/enumeral.rb
178
+ - app/models/studio/link.rb
174
179
  - app/models/theme_setting.rb
175
180
  - app/services/google_oauth_validator.rb
176
181
  - app/services/magic_link.rb
182
+ - app/services/studio/email_image.rb
177
183
  - app/views/components/_admin_dropdown.html.erb
178
184
  - app/views/components/_avatar.html.erb
179
185
  - app/views/components/_avatar_cropper.html.erb
@@ -192,6 +198,7 @@ files:
192
198
  - app/views/error_logs/index.html.erb
193
199
  - app/views/error_logs/show.html.erb
194
200
  - app/views/layouts/_navbar.html.erb
201
+ - app/views/layouts/branded_mailer.html.erb
195
202
  - app/views/layouts/studio/_flash.html.erb
196
203
  - app/views/layouts/studio/_head.html.erb
197
204
  - app/views/magic_links/confirm.html.erb
@@ -200,6 +207,7 @@ files:
200
207
  - app/views/schema/index.html.erb
201
208
  - app/views/sessions/_sso_continue.html.erb
202
209
  - app/views/sessions/new.html.erb
210
+ - app/views/studio/_confirm_interstitial.html.erb
203
211
  - app/views/studio/_cropper_assets.html.erb
204
212
  - app/views/studio/admin_models/_arenas_table.html.erb
205
213
  - app/views/studio/admin_models/_teams_table.html.erb
@@ -211,6 +219,8 @@ files:
211
219
  - app/views/studio/banners/_email_status_button.html.erb
212
220
  - app/views/studio/banners/_environment.html.erb
213
221
  - app/views/studio/banners/_impersonation.html.erb
222
+ - app/views/studio/email_images/index.html.erb
223
+ - app/views/studio/links/confirm.html.erb
214
224
  - app/views/studio/local_emails/index.html.erb
215
225
  - app/views/studio/modals/_crop_photo.html.erb
216
226
  - app/views/studio/modals/_host.html.erb
@@ -224,6 +234,9 @@ files:
224
234
  - app/views/user_mailer/magic_link.html.erb
225
235
  - app/views/user_mailer/magic_link.text.erb
226
236
  - db/migrate/20260614000000_create_studio_email_deliveries.rb
237
+ - db/migrate/20260620000001_create_studio_links.rb
238
+ - db/migrate/20260620000002_allow_null_image_cache_owner.rb
239
+ - db/migrate/20260623130000_create_studio_enumerals.rb
227
240
  - lib/studio-engine.rb
228
241
  - lib/studio.rb
229
242
  - lib/studio/color_scale.rb
@@ -231,6 +244,7 @@ files:
231
244
  - lib/studio/email_smoke.rb
232
245
  - lib/studio/engine.rb
233
246
  - lib/studio/image_cache.rb
247
+ - lib/studio/link_token.rb
234
248
  - lib/studio/mail_transport.rb
235
249
  - lib/studio/s3.rb
236
250
  - lib/studio/theme_resolver.rb