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/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, 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.31.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)


221
222
223
# File 'lib/studio.rb', line 221

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



186
187
188
# File 'lib/studio.rb', line 186

def self.configure
  yield self
end

.env_truthy?(value) ⇒ Boolean

Returns:

  • (Boolean)


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

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

.env_value(env, key) ⇒ Object



215
216
217
218
# File 'lib/studio.rb', line 215

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



299
300
301
# File 'lib/studio.rb', line 299

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)


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

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

.local_email_capture?Boolean

Returns:

  • (Boolean)


276
277
278
279
280
281
# File 'lib/studio.rb', line 276

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)


307
308
309
# File 'lib/studio.rb', line 307

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)


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

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


380
381
382
383
384
385
386
# File 'lib/studio.rb', line 380

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)


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

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)


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

def self.magic_link_via_l_route?
  draw_link_routes
end

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



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

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



198
199
200
201
202
203
204
205
206
207
# File 'lib/studio.rb', line 198

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)


241
242
243
# File 'lib/studio.rb', line 241

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)


291
292
293
# File 'lib/studio.rb', line 291

def self.qa_environment?
  EnvironmentBanner.qa_environment?
end

.rails_env_nameObject



311
312
313
314
315
# File 'lib/studio.rb', line 311

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

  Rails.env.to_s
end

.routes(router) ⇒ Object



399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
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
# File 'lib/studio.rb', line 399

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)


209
210
211
212
213
# File 'lib/studio.rb', line 209

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)


295
296
297
# File 'lib/studio.rb', line 295

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.



391
392
393
# File 'lib/studio.rb', line 391

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

.theme_configObject



364
365
366
367
368
369
370
371
372
373
374
# File 'lib/studio.rb', line 364

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)


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

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



317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/studio.rb', line 317

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:



334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# File 'lib/studio.rb', line 334

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