Module: Studio

Defined in:
lib/studio/newsletter.rb,
lib/studio.rb,
lib/studio/s3.rb,
lib/studio/geo.rb,
lib/studio/cable.rb,
lib/studio/email.rb,
lib/studio/redis.rb,
lib/studio/engine.rb,
lib/studio/version.rb,
lib/studio/geo/lookup.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/geo/countries.rb,
lib/studio/profile_image.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,
app/helpers/studio/geo_helper.rb,
app/models/studio/geo_setting.rb,
lib/studio/environment_banner.rb,
lib/studio/username_generator.rb,
app/helpers/studio/fizz_helper.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/geo_detection.rb,
app/controllers/concerns/studio/impersonation.rb,
app/controllers/concerns/studio/error_handling.rb,
app/controllers/studio/email_images_controller.rb,
app/controllers/studio/geo_settings_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.

THE ENGINE PREPENDS ONE SECTION OF ITS OWN — "You", linking to /profile. The engine ships that page, so it ships the way in rather than asking five apps to declare the same entry and watch them drift. See standard below for the two gates it carries.

Defined Under Namespace

Modules: AdminModels, AdminModelsTableHelper, AtTimeHelper, Board, Broadcastable, Cable, ColorScale, Email, EmailCatalog, EmailImage, EnvironmentBanner, ErrorHandling, FizzHelper, Geo, GeoDetection, GeoHelper, ImageCache, Impersonation, IpLocations, LinkConsumption, LinkResolution, LinkToken, LogRotation, MagicLinkIssuing, Newsletter, OauthIdentity, ProfileImage, ProfileSections, Redis, S3, SidebarSections, UiPrimitives, UserProfile Classes: Banner, EmailDelivery, EmailDeliveryJob, EmailImagesController, EmailPreviewTarget, EmailSetting, EmailSmoke, EmailsController, Engine, Enumeral, GeoSetting, GeoSettingsController, 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.64.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)


588
589
590
# File 'lib/studio.rb', line 588

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



553
554
555
# File 'lib/studio.rb', line 553

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.



765
766
767
# File 'lib/studio.rb', line 765

def self.default_profile_sections
  ProfileSections.defaults
end

.env_truthy?(value) ⇒ Boolean

Returns:

  • (Boolean)


777
778
779
# File 'lib/studio.rb', line 777

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

.env_value(env, key) ⇒ Object



582
583
584
585
# File 'lib/studio.rb', line 582

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



666
667
668
# File 'lib/studio.rb', line 666

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)


596
597
598
# File 'lib/studio.rb', line 596

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)


403
404
405
406
407
408
409
# File 'lib/studio.rb', line 403

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

.geo_blocking_enabled?Boolean

nil (the default) means "read the environment"; an app that wants to decide in code sets true/false in its initializer and the variable is ignored.

Returns:

  • (Boolean)


358
359
360
361
362
# File 'lib/studio.rb', line 358

def self.geo_blocking_enabled?
  return !!geo_blocking_enabled unless geo_blocking_enabled.nil?

  env_truthy?(ENV["ENABLE_GEO_BLOCKING"])
end

.local_email_capture?Boolean

Returns:

  • (Boolean)


643
644
645
646
647
648
# File 'lib/studio.rb', line 643

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)


674
675
676
# File 'lib/studio.rb', line 674

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)


637
638
639
640
641
# File 'lib/studio.rb', line 637

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


747
748
749
750
751
752
753
# File 'lib/studio.rb', line 747

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)


221
222
223
224
225
226
227
228
229
230
231
# File 'lib/studio.rb', line 221

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)


627
628
629
# File 'lib/studio.rb', line 627

def self.magic_link_via_l_route?
  draw_link_routes
end

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



557
558
559
560
561
562
563
# File 'lib/studio.rb', line 557

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



565
566
567
568
569
570
571
572
573
574
# File 'lib/studio.rb', line 565

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)


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

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

.profile_sections_for(view, page: nil) ⇒ 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.



773
774
775
# File 'lib/studio.rb', line 773

def self.profile_sections_for(view, page: nil)
  ProfileSections.resolve(profile_sections, view, page: page)
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)


658
659
660
# File 'lib/studio.rb', line 658

def self.qa_environment?
  EnvironmentBanner.qa_environment?
end

.rails_env_nameObject



678
679
680
681
682
# File 'lib/studio.rb', line 678

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.



426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
# File 'lib/studio.rb', line 426

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



781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
# File 'lib/studio.rb', line 781

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),
    # plus the MOBILE deep-link callback Phantom redirects back to. The
    # callback used to be listed here as app-specific and is not any more —
    # it was promoted with the deep link itself, because the hub had no
    # mobile wallet path at all and copying 400 lines of SIWS protocol per
    # app is how the picker came to exist three times. Account-linking and
    # the OAuth popup DO stay app-side.
    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
      get  "auth/phantom/callback", to: "solana_sessions#phantom_callback",
           as: :phantom_callback
    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
      get    "profile/edit",   to: "studio/profiles#edit",   as: :edit_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.
      delete "profile/google", to: "studio/profiles#unlink_google",
             as: :profile_unlink_google

      # POST joins, DELETE leaves — the verbs the two actions actually are, on
      # one path. Not a PATCH on the profile: subscribing is its own decision
      # with its own confirmation, and folding it into the bulk field save would
      # mean every name change re-asserted a mailing-list preference.
      post   "profile/newsletter", to: "studio/profiles#subscribe_newsletter",
             as: :profile_newsletter
      delete "profile/newsletter", to: "studio/profiles#unsubscribe_newsletter"
    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 shared geo manager (/admin/geo) + the public detection probe
    # (/geo/check). OPT-IN — see Studio.draw_geo_routes: turf-monster owns all
    # four of these helper names today, and drawing a name an app already has
    # raises `Invalid route name, already in use` while that app's routes.rb
    # loads, which kills every route in it. An app opts in from its initializer
    # once its local copies are deleted:
    #
    #   config.draw_geo_routes = true
    #
    # This gates only the PAGE and the probe. Detection, the badge, the policy
    # and the gate are always available to an app that includes
    # Studio::GeoDetection — an app is geo-aware whether or not it draws these.
    if Studio.draw_geo_routes
      get   "geo/check",        to: "studio/geo_settings#check",           as: :geo_check
      get   "admin/geo",        to: "studio/geo_settings#edit",            as: :admin_geo
      patch "admin/geo",        to: "studio/geo_settings#update",          as: :admin_geo_update
      post  "admin/geo/toggle", to: "studio/geo_settings#toggle_override", as: :admin_geo_toggle
    end
    # 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)


576
577
578
579
580
# File 'lib/studio.rb', line 576

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)


662
663
664
# File 'lib/studio.rb', line 662

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.



758
759
760
# File 'lib/studio.rb', line 758

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

.theme_configObject



731
732
733
734
735
736
737
738
739
740
741
# File 'lib/studio.rb', line 731

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)


614
615
616
617
618
619
620
# File 'lib/studio.rb', line 614

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



684
685
686
687
688
689
690
691
692
693
694
695
# File 'lib/studio.rb', line 684

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:



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
# File 'lib/studio.rb', line 701

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

.wallet_debug_sink?Boolean

Returns:

  • (Boolean)


98
# File 'lib/studio.rb', line 98

def self.wallet_debug_sink? = wallet_debug_sink.call

.wallet_sign_in_statementObject



78
79
80
# File 'lib/studio.rb', line 78

def self.
  .call
end