Module: Studio

Defined in:
lib/studio/sidebar_sections.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/theme_resolver.rb,
app/models/studio/enumeral.rb,
app/services/studio/banner.rb,
lib/studio/link_resolution.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/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/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/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, Redis, S3, SidebarSections, UiPrimitives Classes: Banner, EmailDelivery, EmailDeliveryJob, EmailImagesController, EmailPreviewTarget, EmailSetting, EmailSmoke, EmailsController, Engine, Enumeral, Link, LinksController, LocalEmailsController, LocalReviewsController, MailTransport, ModelPage, ModelsController, NewsletterMailer, OnboardingController, 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
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.47.1"

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)


374
375
376
# File 'lib/studio.rb', line 374

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



339
340
341
# File 'lib/studio.rb', line 339

def self.configure
  yield self
end

.env_truthy?(value) ⇒ Boolean

Returns:

  • (Boolean)


548
549
550
# File 'lib/studio.rb', line 548

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

.env_value(env, key) ⇒ Object



368
369
370
371
# File 'lib/studio.rb', line 368

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



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

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)


382
383
384
# File 'lib/studio.rb', line 382

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)


189
190
191
192
193
194
195
# File 'lib/studio.rb', line 189

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)


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

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)


460
461
462
# File 'lib/studio.rb', line 460

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)


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

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


533
534
535
536
537
538
539
# File 'lib/studio.rb', line 533

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)


109
110
111
112
113
114
115
116
117
118
119
# File 'lib/studio.rb', line 109

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)


413
414
415
# File 'lib/studio.rb', line 413

def self.magic_link_via_l_route?
  draw_link_routes
end

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



343
344
345
346
347
348
349
# File 'lib/studio.rb', line 343

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



351
352
353
354
355
356
357
358
359
360
# File 'lib/studio.rb', line 351

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)


394
395
396
# File 'lib/studio.rb', line 394

def self.
  auth_method?(:password) && user_supports_password?
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)


444
445
446
# File 'lib/studio.rb', line 444

def self.qa_environment?
  EnvironmentBanner.qa_environment?
end

.rails_env_nameObject



464
465
466
467
468
# File 'lib/studio.rb', line 464

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.



212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/studio.rb', line 212

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



552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
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
# File 'lib/studio.rb', line 552

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

    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)


362
363
364
365
366
# File 'lib/studio.rb', line 362

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)


448
449
450
# File 'lib/studio.rb', line 448

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.



544
545
546
# File 'lib/studio.rb', line 544

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

.theme_configObject



517
518
519
520
521
522
523
524
525
526
527
# File 'lib/studio.rb', line 517

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)


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

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



470
471
472
473
474
475
476
477
478
479
480
481
# File 'lib/studio.rb', line 470

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:



487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
# File 'lib/studio.rb', line 487

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