Module: Studio

Defined in:
lib/studio/profile_image.rb,
lib/studio.rb,
lib/studio/s3.rb,
lib/studio/cable.rb,
lib/studio/email.rb,
lib/studio/redis.rb,
lib/studio/engine.rb,
lib/studio/version.rb,
lib/studio/link_token.rb,
app/models/studio/link.rb,
lib/studio/color_scale.rb,
lib/studio/email_smoke.rb,
lib/studio/image_cache.rb,
lib/studio/ip_locations.rb,
lib/studio/log_rotation.rb,
lib/studio/ui_primitives.rb,
lib/studio/mail_transport.rb,
lib/studio/oauth_identity.rb,
lib/studio/theme_resolver.rb,
app/models/studio/enumeral.rb,
app/services/studio/banner.rb,
lib/studio/link_resolution.rb,
lib/studio/profile_sections.rb,
lib/studio/sidebar_sections.rb,
app/models/studio/model_page.rb,
lib/studio/environment_banner.rb,
lib/studio/username_generator.rb,
app/models/studio/email_setting.rb,
app/services/studio/email_image.rb,
app/models/studio/email_delivery.rb,
app/helpers/studio/at_time_helper.rb,
app/mailers/studio/profile_mailer.rb,
app/services/studio/email_catalog.rb,
app/jobs/studio/email_delivery_job.rb,
app/mailers/studio/newsletter_mailer.rb,
app/controllers/studio/links_controller.rb,
app/models/concerns/studio/user_profile.rb,
app/controllers/studio/emails_controller.rb,
app/controllers/studio/models_controller.rb,
app/models/concerns/studio/broadcastable.rb,
app/services/studio/email_preview_target.rb,
app/models/concerns/studio/board/rankable.rb,
app/controllers/studio/profiles_controller.rb,
app/controllers/concerns/studio/admin_models.rb,
app/controllers/studio/onboarding_controller.rb,
app/helpers/studio/admin_models_table_helper.rb,
app/controllers/concerns/studio/impersonation.rb,
app/controllers/concerns/studio/error_handling.rb,
app/controllers/studio/email_images_controller.rb,
app/controllers/studio/local_emails_controller.rb,
app/controllers/studio/local_reviews_controller.rb,
app/controllers/concerns/studio/link_consumption.rb,
app/controllers/concerns/studio/board/reorderable.rb,
app/controllers/concerns/studio/magic_link_issuing.rb

Overview

Resolves Studio.sidebar_sections — the host's declared link-sidebar data — for a given view context. Pure Ruby (no Rails dependency) so the unit suite exercises the resolution rules without booting the dummy app.

Declared sections may be a static Array or a callable (receives the view context) for dynamic data: route helpers, logged_in? walls, model-backed link lists. Each section normalizes to symbol keys:

{ title: "Site", admin: true, links: [
{ label: "Dashboard", href: "/admin", emoji: "📊",
  hover_emoji: "🔬", desc: "Users + logs", target: "_blank" } ] }

Sections flagged admin: true resolve only for admin? viewers, so the trigger and panel stay invisible to everyone else even when the host declares nothing but admin links.

Defined Under Namespace

Modules: AdminModels, AdminModelsTableHelper, AtTimeHelper, Board, Broadcastable, Cable, ColorScale, Email, EmailCatalog, EmailImage, EnvironmentBanner, ErrorHandling, ImageCache, Impersonation, IpLocations, LinkConsumption, LinkResolution, LinkToken, LogRotation, MagicLinkIssuing, OauthIdentity, ProfileImage, ProfileSections, Redis, S3, SidebarSections, UiPrimitives, UserProfile Classes: Banner, EmailDelivery, EmailDeliveryJob, EmailImagesController, EmailPreviewTarget, EmailSetting, EmailSmoke, EmailsController, Engine, Enumeral, Link, LinksController, LocalEmailsController, LocalReviewsController, MailTransport, ModelPage, ModelsController, NewsletterMailer, OnboardingController, ProfileMailer, ProfilesController, S3ConfigError, ThemeResolver, UserContractError, UsernameGenerator

Constant Summary collapse

FIRST_NAME_SKIP_SESSION_KEY =

Session key recording "asked, and they said not now". Session-scoped DELIBERATELY: skipping means not now, not never — the field stays blank, so a later session may ask again. That is the whole reason this is not a column.

:onboarding_skipped_first_name
FIRST_NAME_MAX_LENGTH =

How long a first name may be. ONE constant because users.first_name is written from TWO surfaces — the onboarding step (seconds after signup) and /profile (any time after) — and rendered by a third, the profile form's maxlength. Two independently-correct caps that disagreed would let onboarding accept a name /profile then refused to save, a bug with no obvious owner.

Keeping it here rather than on either controller also keeps the VIEW off a controller constant: the form needs the number, and a view reaching into Studio::ProfilesController to get it would couple the two for no reason.

40
REQUIRED_USER_INSTANCE_METHODS =

Only methods that consumers must explicitly define are checked here. Column accessors (#email, #name, #role) are NOT validated because ActiveRecord defines them lazily — they don't appear on .instance_methods until the schema is introspected (typically first record access). Missing columns are caught by the User table schema, not by this validator.

%i[admin? display_name].freeze
REQUIRED_USER_CLASS_METHODS =
%i[find_by].freeze
PASSWORD_USER_INSTANCE_METHODS =

#authenticate is only required when email+password sign-in is enabled. Passwordless apps (the default) never call it.

%i[authenticate].freeze
VERSION =
"0.49.0"

Class Method Summary collapse

Class Method Details

.auth_method?(method) ⇒ Boolean

True when the given sign-in method is enabled for this app.

Returns:

  • (Boolean)


431
432
433
# File 'lib/studio.rb', line 431

def self.auth_method?(method)
  auth_methods.include?(method.to_sym)
end

.configure {|_self| ... } ⇒ Object

Yields:

  • (_self)

Yield Parameters:

  • _self (Studio)

    the object that the method was called on



396
397
398
# File 'lib/studio.rb', line 396

def self.configure
  yield self
end

.default_profile_sectionsObject

The engine's standard /profile rows. Hosts compose against this rather than restating a literal list, so a later release that adds a standard row delivers it to every app that used the seam as intended.



608
609
610
# File 'lib/studio.rb', line 608

def self.default_profile_sections
  ProfileSections.defaults
end

.env_truthy?(value) ⇒ Boolean

Returns:

  • (Boolean)


620
621
622
# File 'lib/studio.rb', line 620

def self.env_truthy?(value)
  %w[1 true yes on].include?(value.to_s.strip.downcase)
end

.env_value(env, key) ⇒ Object



425
426
427
428
# File 'lib/studio.rb', line 425

def self.env_value(env, key)
  value = env[key]
  value if value && !value.to_s.strip.empty?
end

.environment_banner_message(rails_env: rails_env_name, extra: []) ⇒ Object



509
510
511
# File 'lib/studio.rb', line 509

def self.environment_banner_message(rails_env: rails_env_name, extra: [])
  EnvironmentBanner.message(rails_env: rails_env, qa_environment: qa_environment?, extra: extra)
end

.feature?(name) ⇒ Boolean

True when the given capability feature is enabled for this app. Mirrors auth_method? — apps opt in via config.features in their initializer (see the Studio.features accessor). Tolerates String or Symbol entries and any Enumerable (Array or Set), so feature?("web3") and feature?(:web3) agree.

Returns:

  • (Boolean)


439
440
441
# File 'lib/studio.rb', line 439

def self.feature?(name)
  features.any? { |f| f.to_sym == name.to_sym }
end

.first_name_outstanding?(user, session = {}) ⇒ Boolean

The shared rule for "does this account still owe us a first name?" — the one piece of onboarding logic every app agrees on. Hosts compose it into their own flow rather than re-deriving it (turf's OnboardingFlow calls straight through).

Tolerates a host whose users table has no first_name column: an app that has not run the migration yet is simply never asked, instead of raising on every signed-in request.

Returns:

  • (Boolean)


246
247
248
249
250
251
252
# File 'lib/studio.rb', line 246

def self.first_name_outstanding?(user, session = {})
  return false if user.blank?
  return false unless user.respond_to?(:first_name)
  return false if session.present? && session[FIRST_NAME_SKIP_SESSION_KEY]

  user.first_name.blank?
end

.local_email_capture?Boolean

Returns:

  • (Boolean)


486
487
488
489
490
491
# File 'lib/studio.rb', line 486

def self.local_email_capture?
  return false if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
  return !!local_email_capture unless local_email_capture.nil?

  env_truthy?(ENV["LOCAL_EMAIL_CAPTURE"]) || env_truthy?(ENV["AGENT_WORKTREE"])
end

.local_inbox_reachable?(request_local:) ⇒ Boolean

Whether the local email inbox is actually REACHABLE for this request, which is the only honest reason to render a link to it. Deliberately the same gate the controller enforces (local_tool_enabled?), so the banner can never advertise a page that answers 404 — QA gets a status chip instead.

Returns:

  • (Boolean)


517
518
519
# File 'lib/studio.rb', line 517

def self.local_inbox_reachable?(request_local:)
  local_tool_enabled?(request_local: request_local)
end

.local_tool_enabled?(request_local:) ⇒ Boolean

The floor every developer-desk tool sits on: the local email inbox (Studio::LocalEmailsController) and the local-review mint (Studio::LocalReviewsController). Both hand out sign-in material without authenticating anyone, so both are OFF in production and OFF for any request that did not come from the loopback interface. One spelling, so a tool added later cannot quietly ship a weaker gate. Pass request.local? in.

Returns:

  • (Boolean)


480
481
482
483
484
# File 'lib/studio.rb', line 480

def self.local_tool_enabled?(request_local:)
  return false if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?

  !!request_local
end

.logo_for(title) ⇒ Object

Find a logo from theme_logos by title, with fallback chain:

  1. Exact title match
  2. "Navbar Logo" fallback
  3. First logo in the list


590
591
592
593
594
595
596
# File 'lib/studio.rb', line 590

def self.logo_for(title)
  logos = theme_logos.map { |l| l.is_a?(Hash) ? l : { file: l, title: l } }
  entry = logos.find { |l| l[:title] == title }
  entry ||= logos.find { |l| l[:title] == "Navbar Logo" }
  entry ||= logos.first
  entry ? "/#{entry[:file]}" : nil
end

to_s.to_sym, not to_sym: this runs from an initializer, and nil or an Integer would raise NoMethodError — swallowing the explanation below with a message that says nothing about what to do. A blank falls through to the raise instead, so the operator reads the actual instruction.

Raises:

  • (ArgumentError)


155
156
157
158
159
160
161
162
163
164
165
# File 'lib/studio.rb', line 155

def self.magic_link_store=(value)
  return if value.to_s.to_sym == :database

  raise ArgumentError,
        "Studio.magic_link_store = #{value.inspect} is retired (studio-engine 0.31.0). " \
        "Magic links are Studio::Link rows served at /l/<token>. Delete this line from " \
        "config/initializers/studio.rb, then install the table with " \
        "`bin/rails studio_engine:install:migrations && bin/rails db:migrate` — in that " \
        "order, because this raise fires while the initializer loads and no rake task can " \
        "boot until the line is gone."
end

True when the emailed/inbox magic-link URL is the short /l/ — the standard. False means this app draws its own token route instead and owns the matching consume: turf-monster keeps /magic_link/ because /l is already its landing-page namespace. Either way the TOKEN is the same short Studio::Link token; only the path in front of it differs.

Returns:

  • (Boolean)


470
471
472
# File 'lib/studio.rb', line 470

def self.magic_link_via_l_route?
  draw_link_routes
end

.mailer_from_for_transport(env: ENV, ses_from:, resend_from: nil) ⇒ Object



400
401
402
403
404
405
406
# File 'lib/studio.rb', line 400

def self.mailer_from_for_transport(env: ENV, ses_from:, resend_from: nil)
  if ses_transport_ready?(env)
    env_value(env, "MAILER_FROM") || ses_from
  else
    env_value(env, "RESEND_MAILER_FROM") || resend_from || resend_mailer_from
  end
end

.marketing_from_for_transport(env: ENV, ses_from:, resend_from: nil) ⇒ Object



408
409
410
411
412
413
414
415
416
417
# File 'lib/studio.rb', line 408

def self.marketing_from_for_transport(env: ENV, ses_from:, resend_from: nil)
  if ses_transport_ready?(env)
    env_value(env, "MARKETING_MAILER_FROM") || ses_from
  else
    env_value(env, "RESEND_MARKETING_FROM") ||
      env_value(env, "RESEND_MAILER_FROM") ||
      resend_from ||
      resend_mailer_from
  end
end

.password_login_available?Boolean

True when the engine login should render a PASSWORD field/form: passwords are enabled (:password in auth_methods) AND the host User model actually supports them (responds to authenticate — i.e. has_secure_password). Both are required, and the second is the belt-and-suspenders: without it, a passwordless app (User with no authenticate) rendered the hardcoded password field and 500'd on submit via user.authenticate — the whole fleet having moved off passwords, this made the engine default wrong for every consumer. The User check is defensive of a mis-set auth_methods; the contract validation normally guarantees it (validate_user_contract! requires PASSWORD_USER_INSTANCE_METHODS iff auth_method?(:password)).

Returns:

  • (Boolean)


451
452
453
# File 'lib/studio.rb', line 451

def self.
  auth_method?(:password) && user_supports_password?
end

.profile_sections_for(view) ⇒ Object

Profile rows resolved for a view context: a callable config is called with the view, keys symbolize, admin-only rows drop for non-admin viewers, and rows this host's user model cannot serve drop entirely. nil config resolves to the standard page.



616
617
618
# File 'lib/studio.rb', line 616

def self.profile_sections_for(view)
  ProfileSections.resolve(profile_sections, view)
end

.qa_environment?Boolean

True for a stable QA app: Rails-production, but a non-production review target that must identify itself as one. Keyed off QA_ENV, the signal the release conductor already sets on every QA app.

Returns:

  • (Boolean)


501
502
503
# File 'lib/studio.rb', line 501

def self.qa_environment?
  EnvironmentBanner.qa_environment?
end

.rails_env_nameObject



521
522
523
524
525
# File 'lib/studio.rb', line 521

def self.rails_env_name
  return "development" unless defined?(Rails) && Rails.respond_to?(:env)

  Rails.env.to_s
end

.record_ip_location!(user, ip:, country: nil, region: nil, city: nil, at: nil) ⇒ Object

Record a place this account has been seen from, if it is a place we have not seen it from before. Returns true when something was actually written.

NEW LOCATIONS ONLY, and that is the design rather than a shortcut: this is called from the request path, so refreshing a counter on every hit would mean a database write per request for no analytic gain. The first sign-in from a place writes; the next thousand do not. A host wanting last-seen/count refresh can call Studio::IpLocations.push directly on its own cadence.

The host resolves the location — turf-monster already has Geocoder wired in ApplicationController#detect_geo_state — and passes whatever it got. Pass only an IP and the IP is what gets deduped on.

Tolerates an app that has not run the migration (no ip_locations column): it records nothing rather than raising on a request path.



269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/studio.rb', line 269

def self.record_ip_location!(user, ip:, country: nil, region: nil, city: nil, at: nil)
  return false if user.blank?
  return false unless user.respond_to?(:ip_locations)

  current = user.ip_locations
  return false if IpLocations.seen?(current, ip: ip, country: country, region: region, city: city)

  updated = IpLocations.push(current, ip: ip, country: country, region: region,
                                      city: city, at: at)
  return false if updated == IpLocations.normalize(current)

  # update_columns, not update!: analytics must never block a request, and a
  # validation failure elsewhere on the record is not this write's business.
  user.update_columns(ip_locations: updated)
  true
end

.routes(router) ⇒ Object



624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
# File 'lib/studio.rb', line 624

def self.routes(router)
  router.instance_exec do
    get  "login",  to: "sessions#new"
    post "login",  to: "sessions#create"
    post "sso_continue", to: "sessions#sso_continue"
    get  "sso_login",    to: "sessions#sso_login"
    get  "logout", to: "sessions#destroy"
    get  "signup", to: "registrations#new"
    post "signup", to: "registrations#create"
    get  "auth/:provider/callback", to: "omniauth_callbacks#create"
    get  "auth/failure", to: "omniauth_callbacks#failure"

    # Developer-desk tools. Drawn outside production, and each controller
    # re-checks Studio.local_tool_enabled? per request (loopback only) — the
    # route being absent is the outer gate, not the only one.
    unless defined?(Rails) && Rails.env.production?
      get "_studio/local_emails", to: "studio/local_emails#index", as: :studio_local_emails
      get "_studio/local_review", to: "studio/local_reviews#show",  as: :studio_local_review
    end

    # Passwordless email (magic link) — the REQUEST half only. Helper:
    # magic_link_request_path (POST an email address, get a link mailed).
    #
    # The token-bearing half moved to /l/<token> below (0.31.0). There is one
    # token format now — a short Studio::Link row — and one place that burns
    # it, so the old /magic_link/:token confirm+consume pair would have been a
    # second door onto the same lock.
    if Studio.draw_auth_routes && Studio.auth_method?(:magic_link)
      post "magic_link", to: "magic_links#create", as: :magic_link_request
    end

    # Unified short-token links — /l/<token> for magic sign-in links + referral
    # links (Studio::Link). Studio::LinksController dispatches by kind: a
    # magic_link renders the scanner-safe confirm interstitial then POSTs to
    # consume; a referral captures attribution + redirects. Helpers: link_path
    # / link_url(token:) and link_consume_path. Drawn for every consumer
    # (including draw_auth_routes=false apps) unless draw_link_routes is off.
    if Studio.draw_link_routes
      get  "l/:token", to: "studio/links#show",    as: :link,
           constraints: { token: %r{[^/]+} }
      post "l/:token", to: "studio/links#consume", as: :link_consume,
           constraints: { token: %r{[^/]+} }
    end

    # Solana / Phantom wallet sign-in (nonce challenge + signature verify).
    # The browser posts to these literal paths from the shared Connect-Wallet
    # flow; app-specific surfaces (mobile deep-link callback, account-linking,
    # OAuth popup) stay in the consuming app's routes.
    if Studio.draw_auth_routes && Studio.auth_method?(:wallet)
      get  "auth/solana/nonce",  to: "solana_sessions#nonce",  as: :solana_nonce
      post "auth/solana/verify", to: "solana_sessions#verify", as: :solana_verify
    end

    # The shared profile page. ON by default — unlike /admin/emails and the
    # onboarding pair, `profile` is claimed by no consumer, so drawing it
    # cannot take an app's route set down. See Studio.draw_profile_routes.
    #
    # Avatar is its own PATCH rather than a field on #update: an attachment
    # param submitted empty PURGES the attachment, so a single form carrying
    # both would delete someone's photo every time they edited their name.
    if Studio.draw_profile_routes
      get    "profile",        to: "studio/profiles#show",   as: :profile
      patch  "profile",        to: "studio/profiles#update"
      patch  "profile/avatar", to: "studio/profiles#avatar", as: :profile_avatar
      # DELETE, because unlinking removes an identity. Linking is not drawn
      # here: it is OmniAuth's own /auth/:provider, which the middleware owns.
      patch  "profile/email",  to: "studio/profiles#email", as: :profile_email
      delete "profile/google", to: "studio/profiles#unlink_google",
             as: :profile_unlink_google

    end

    resources :error_logs, only: [:index, :show]

    # Admin
    get   "admin/theme",            to: "theme_settings#edit",       as: :admin_theme
    patch "admin/theme",            to: "theme_settings#update",     as: :admin_theme_update
    post  "admin/theme/regenerate", to: "theme_settings#regenerate", as: :admin_theme_regenerate
    get   "admin/schema",           to: "schema#index",              as: :admin_schema
    # The living style guide. Canonical at /admin/style (StyleController#index);
    # /admin/design_system redirects here but KEEPS its admin_design_system_path
    # helper so a shipped host sidebar link on the old helper still resolves.
    get   "admin/style",            to: "style#index",               as: :admin_style
    get   "admin/design_system",    to: redirect("/admin/style"),    as: :admin_design_system

    # The standard transactional-email page. Canonical at /admin/emails
    # (Studio::EmailsController): index lists every registered email with its
    # live banner and whether that banner is inherited or app-owned; update
    # stores this app's own override; destroy drops it back to the inherited
    # default. Surfaced from each app's admin sidebar.
    #
    # /admin/email_images redirects here but KEEPS its admin_email_images_path
    # helper, so a shipped host sidebar link on the old helper still resolves
    # (same treatment as /admin/design_system -> /admin/style).
    # OPT-IN, and it has to be. turf-monster ALREADY owns /admin/emails —
    # `namespace :admin { get "emails", as: :emails }` (its EmailCatalog
    # manager) — which claims the SAME path and the SAME helper names,
    # admin_emails_path and admin_email_path. Drawing these unconditionally
    # raises `ArgumentError: Invalid route name, already in use: 'admin_emails'`
    # while turf-monster's own routes.rb is loading, which takes down its
    # ENTIRE route set (every admin_*_path in the app goes undefined) — not a
    # shadowed page, a dead app. Confirmed on consumer CI, PR #86.
    #
    # A host cannot opt out of something that breaks it before its config is
    # read, and consumer CI runs each consumer's `main` — so default-on cannot
    # be fixed from inside the engine. Default-off, and each app's adoption
    # task turns it on. Flip the default once no consumer's main owns the name.
    #
    # This gates only the PAGE. The registry and the two-layer image resolution
    # are always on, and the engine's own UserMailer already calls
    # Studio::EmailImage.resolved_url — so an app is branded on day one whether
    # or not it draws the page.
    if Studio.draw_admin_emails_routes
      get    "admin/emails",          to: "studio/emails#index", as: :admin_emails
      # /raw is drawn BEFORE /:key so "raw" is never captured as a key.
      get    "admin/emails/:key/raw", to: "studio/emails#raw",   as: :admin_email_raw,
             constraints: { key: /[a-z0-9_]+/ }
      get    "admin/emails/:key",     to: "studio/emails#show",  as: :admin_email,
             constraints: { key: /[a-z0-9_]+/ }
      # Same path, same helper (admin_email_path) — a named route only needs
      # to be declared once per name, and these share the show route's URL.
      patch  "admin/emails/:key",     to: "studio/emails#update",
             constraints: { key: /[a-z0-9_]+/ }
      delete "admin/emails/:key",     to: "studio/emails#destroy",
             constraints: { key: /[a-z0-9_]+/ }
      # Operator-tunable per-email settings (the banner scrim today). Separate
      # from #update, which takes an image upload.
      patch  "admin/emails/:key/settings", to: "studio/emails#settings",
             as: :admin_email_settings, constraints: { key: /[a-z0-9_]+/ }
      # The banner's words and logo. Its own route so writing a sentence and
      # nudging the tint save independently.
      patch  "admin/emails/:key/copy", to: "studio/emails#copy",
             as: :admin_email_copy, constraints: { key: /[a-z0-9_]+/ }
      # The per-email logo. Separate from the banner upload above — different
      # picture, different ImageCache purpose, independently revertible.
      patch  "admin/emails/:key/logo", to: "studio/emails#logo",
             as: :admin_email_logo, constraints: { key: /[a-z0-9_]+/ }
    end

    # The shared first-name onboarding step's two writes. OPT-IN — see
    # Studio.draw_onboarding_routes above: turf-monster owns these exact helper
    # names today, and drawing them there before its adoption task deletes the
    # local pair kills every route in that app.
    #
    # The paths match the partial's defaults, so a host that opts in and has no
    # other onboarding of its own needs no further wiring.
    if Studio.draw_onboarding_routes
      post "onboarding/first_name",      to: "studio/onboarding#first_name",
           as: :onboarding_first_name
      post "onboarding/skip_first_name", to: "studio/onboarding#skip_first_name",
           as: :onboarding_skip_first_name
    end

    # DEPRECATED, kept for ONE release. Not a redirect: consumer-ci.yml runs
    # each consumer's DEFAULT-BRANCH suite against this engine, and both
    # mcritchie-studio and turf-monster have tests on `main` that GET this page
    # and PATCH through admin_email_image_path. Redirecting (or deleting) here
    # reddens their lanes the moment the PR opens, and no change inside the
    # engine PR can fix it. Each app's adoption task moves its link + tests; a
    # later engine minor deletes these two routes with the controller and view.
    get   "admin/email_images",          to: "studio/email_images#index",  as: :admin_email_images
    patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
          constraints: { variant: /[a-z_]+/ }

    # Model-page protocol (v1) — a reusable per-record inspector. Drawn into
    # every consuming app: /models/:model/:id renders one record as pretty JSON
    # plus a copy/paste rails-console command; /models/:model/random bounces to
    # a random record of that model. Admin-only (Studio::ModelsController).
    # Ships an EMPTY registry — a host enables a model in an initializer with
    # `Studio::ModelPage.register("release", Release, lookup: :slug)`. `random`
    # is drawn BEFORE `:id` so it is not captured as a record identifier.
    get "models/:model/random", to: "studio/models#random", as: :studio_model_random
    get "models/:model/:id",    to: "studio/models#show",   as: :studio_model
  end
end

.ses_transport_ready?(env = ENV) ⇒ Boolean

Returns:

  • (Boolean)


419
420
421
422
423
# File 'lib/studio.rb', line 419

def self.ses_transport_ready?(env = ENV)
  env["MAIL_TRANSPORT"].to_s.downcase == "ses" &&
    env_value(env, "SES_SMTP_USERNAME") &&
    env_value(env, "SES_SMTP_PASSWORD")
end

.show_environment_banner?(rails_env: rails_env_name) ⇒ Boolean

Returns:

  • (Boolean)


505
506
507
# File 'lib/studio.rb', line 505

def self.show_environment_banner?(rails_env: rails_env_name)
  EnvironmentBanner.show?(rails_env: rails_env, qa_environment: qa_environment?)
end

Sidebar sections resolved for a view context: a callable config is called with the view, keys symbolize, and admin-only sections drop for non-admin viewers. Rendering gates on .any?, so [] keeps the navbar untouched.



601
602
603
# File 'lib/studio.rb', line 601

def self.sidebar_sections_for(view)
  SidebarSections.resolve(sidebar_sections, view)
end

.theme_configObject



574
575
576
577
578
579
580
581
582
583
584
# File 'lib/studio.rb', line 574

def self.theme_config
  {
    primary: theme_primary,
    dark:    theme_dark,
    light:   theme_light,
    success: theme_success,
    warning: theme_warning,
    danger:  theme_danger,
    accent:  theme_accent
  }.compact
end

.user_supports_password?Boolean

Does the host User model respond to authenticate (has_secure_password)? Safe when no User is defined (an engine-only boot / a test with no host model) — answers false rather than raise.

Returns:

  • (Boolean)


457
458
459
460
461
462
463
# File 'lib/studio.rb', line 457

def self.user_supports_password?
  return false unless defined?(::User) && ::User.respond_to?(:instance_methods)

  PASSWORD_USER_INSTANCE_METHODS.all? { |m| ::User.instance_methods.include?(m) }
rescue StandardError
  false
end

.user_wallet_address(user) ⇒ Object



527
528
529
530
531
532
533
534
535
536
537
538
# File 'lib/studio.rb', line 527

def self.user_wallet_address(user)
  return nil unless user

  [wallet_address_method, :wallet_address, :solana_address].compact.each do |method|
    next unless user.respond_to?(method)

    value = user.public_send(method)
    return value if value && !(value.respond_to?(:empty?) && value.empty?)
  end

  nil
end

.validate_user_contract!(user_class) ⇒ Object

Verifies that the host app's User model satisfies the engine's expected contract. Raises Studio::UserContractError with a clear pointer to docs/USER_CONTRACT.md if anything required is missing. Called from Engine#after_initialize. Opt out via Studio.validate_user_contract = false.

Raises:



544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
# File 'lib/studio.rb', line 544

def self.validate_user_contract!(user_class)
  return unless validate_user_contract
  return unless user_class.is_a?(Class)

  missing = []
  REQUIRED_USER_CLASS_METHODS.each do |m|
    missing << "User.#{m}" unless user_class.respond_to?(m)
  end
  instance_methods = REQUIRED_USER_INSTANCE_METHODS.dup
  instance_methods.concat(PASSWORD_USER_INSTANCE_METHODS) if auth_method?(:password)
  instance_methods.each do |m|
    missing << "User##{m}" unless user_class.instance_methods.include?(m)
  end

  return if missing.empty?

  raise UserContractError, <<~MSG
    The studio-engine gem's expected User model contract is not satisfied.

    Missing: #{missing.join(", ")}

    See the USER_CONTRACT.md doc in the studio-engine repo for the full
    contract + a minimal compliant example:
      https://github.com/McRitchie-Studio/studio-engine/blob/main/docs/USER_CONTRACT.md

    To bypass this check temporarily, set Studio.validate_user_contract = false
    in config/initializers/studio.rb.
  MSG
end