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/log_rotation.rb,
lib/studio/ui_primitives.rb,
lib/studio/mail_transport.rb,
lib/studio/theme_resolver.rb,
app/models/studio/enumeral.rb,
lib/studio/link_resolution.rb,
app/models/studio/model_page.rb,
lib/studio/environment_banner.rb,
lib/studio/username_generator.rb,
app/services/studio/email_image.rb,
app/models/studio/email_delivery.rb,
app/jobs/studio/email_delivery_job.rb,
app/controllers/studio/links_controller.rb,
app/controllers/studio/models_controller.rb,
app/models/concerns/studio/broadcastable.rb,
app/models/concerns/studio/board/rankable.rb,
app/controllers/concerns/studio/admin_models.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, Board, Broadcastable, Cable, ColorScale, Email, EmailImage, EnvironmentBanner, ErrorHandling, ImageCache, Impersonation, LinkConsumption, LinkResolution, LinkToken, LogRotation, MagicLinkIssuing, Redis, S3, SidebarSections, UiPrimitives Classes: EmailDelivery, EmailDeliveryJob, EmailImagesController, EmailSmoke, Engine, Enumeral, Link, LinksController, LocalEmailsController, LocalReviewsController, MailTransport, ModelPage, ModelsController, S3ConfigError, ThemeResolver, UserContractError, UsernameGenerator

Constant Summary collapse

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.36.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)


264
265
266
# File 'lib/studio.rb', line 264

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



229
230
231
# File 'lib/studio.rb', line 229

def self.configure
  yield self
end

.env_truthy?(value) ⇒ Boolean

Returns:

  • (Boolean)


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

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

.env_value(env, key) ⇒ Object



258
259
260
261
# File 'lib/studio.rb', line 258

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



342
343
344
# File 'lib/studio.rb', line 342

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)


272
273
274
# File 'lib/studio.rb', line 272

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

.local_email_capture?Boolean

Returns:

  • (Boolean)


319
320
321
322
323
324
# File 'lib/studio.rb', line 319

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)


350
351
352
# File 'lib/studio.rb', line 350

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)


313
314
315
316
317
# File 'lib/studio.rb', line 313

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


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

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)


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

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)


303
304
305
# File 'lib/studio.rb', line 303

def self.magic_link_via_l_route?
  draw_link_routes
end

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



233
234
235
236
237
238
239
# File 'lib/studio.rb', line 233

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



241
242
243
244
245
246
247
248
249
250
# File 'lib/studio.rb', line 241

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)


284
285
286
# File 'lib/studio.rb', line 284

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)


334
335
336
# File 'lib/studio.rb', line 334

def self.qa_environment?
  EnvironmentBanner.qa_environment?
end

.rails_env_nameObject



354
355
356
357
358
# File 'lib/studio.rb', line 354

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

  Rails.env.to_s
end

.routes(router) ⇒ Object



442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
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
516
517
518
519
520
521
522
523
524
525
# File 'lib/studio.rb', line 442

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

    # Admin-managed transactional-email banner images (Studio::EmailImage).
    # index lists each managed email variant + its current banner; update
    # uploads a replacement. Surfaced from each app's admin hub.
    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)


252
253
254
255
256
# File 'lib/studio.rb', line 252

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)


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

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.



434
435
436
# File 'lib/studio.rb', line 434

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

.theme_configObject



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

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)


290
291
292
293
294
295
296
# File 'lib/studio.rb', line 290

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



360
361
362
363
364
365
366
367
368
369
370
371
# File 'lib/studio.rb', line 360

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:



377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
# File 'lib/studio.rb', line 377

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