# frozen_string_literal: true # ============================================================================= # Whitelabel — Centralised brand config + licence enforcement # ============================================================================= # # Config loading priority: # 1. Local YAML file → if present, used as-is (dev / custom deploys) # 2. Remote API fetch → if no file, calls Intebec Dashboard (production) # 3. Empty defaults → test environment only # # All accessors use dig() with safe fallbacks so the app never crashes on # missing keys. Without a valid config source the fallbacks return plain # upstream DocuSeal values — your branding only appears with YOUR config. # # Env vars: # INTEBEC_CONFIG_PATH — override local file path (default: /run/secrets/config.yml) # INTEBEC_LICENCE_KEY — licence UUID (required for API mode) # INTEBEC_SECRET_KEY — HMAC shared secret (required for API mode) # INTEBEC_DASHBOARD_URL — override Dashboard URL (default: https://dashboard.intebec.ca) # ============================================================================= require 'yaml' require 'uri' require 'json' require 'openssl' require 'net/http' require 'securerandom' module Whitelabel class ConfigError < StandardError; end class LicenceRevokedError < ConfigError; end CONFIG_PATH = Pathname.new( ENV.fetch('INTEBEC_CONFIG_PATH', '/run/secrets/config.yml') ).freeze DASHBOARD_URL = ENV.fetch('INTEBEC_DASHBOARD_URL', 'https://dashboard.intebec.ca').freeze CONFIG_ENDPOINT = '/api/licences/config' API_TIMEOUT = 10 API_MAX_RETRIES = 3 API_RETRY_DELAY = 2 # seconds, doubles each retry REFRESH_INTERVAL = 24 * 3600 # 24 h REFRESH_ON_ERROR = 5 * 60 # 5 min retry on transient failure THEME_DEFAULTS = { 'primary' => '216 77% 52%', 'primary_focus' => '216 77% 44%', 'primary_content' => '0 0% 100%', 'secondary' => '220 12% 45%', 'secondary_focus' => '220 14% 36%', 'secondary_content' => '0 0% 100%', 'accent' => '160 50% 40%', 'accent_focus' => '160 50% 34%', 'accent_content' => '0 0% 100%', 'neutral' => '220 16% 12%', 'neutral_focus' => '220 16% 8%', 'neutral_content' => '0 0% 100%', 'base_100' => '0 0% 100%', 'base_200' => '220 14% 96%', 'base_300' => '220 12% 93%', 'base_content' => '220 14% 10%', 'info' => '205 80% 50%', 'success' => '154 55% 38%', 'warning' => '38 88% 48%', 'error' => '0 72% 50%', 'rounded_btn' => '1.9rem', 'tab_border' => '2px', 'tab_radius' => '.5rem' }.freeze DEFAULT_STYLING_VARIABLES = { 'ib-bg' => '220 14% 98%', 'ib-surface' => '0 0% 100%', 'ib-surface-2' => '220 14% 96%', 'ib-border' => '220 10% 88%', 'ib-text' => '220 14% 10%', 'ib-text-secondary' => '220 8% 40%', 'ib-muted' => '220 6% 55%' }.freeze # ── Mutable state (thread-safe) ───────────────────────────────────────── @mutex = Mutex.new @config = nil @api_sourced = false @next_refresh = Time.at(0).utc class << self # ===================================================================== # Core # ===================================================================== def config @config || load_config! end def reload! @mutex.synchronize { @config = nil } load_config! end def config_source return :api if @api_sourced return :test if @config && !CONFIG_PATH.file? :file end # Called per-request from ApplicationController. # For API-sourced configs, periodically re-fetches to confirm the # licence is still active and pick up any Dashboard changes. def ensure_valid! return true unless @api_sourced return true unless Time.now.utc >= @next_refresh @mutex.synchronize do return true unless Time.now.utc >= @next_refresh @config = fetch_remote_config @next_refresh = Time.now.utc + REFRESH_INTERVAL rescue LicenceRevokedError # Licence actively revoked → propagate, controller returns 503 @config = {} raise rescue ConfigError => e # Transient error (network, timeout) → keep existing config, retry sooner Rails.logger.error("[Whitelabel] Revalidation failed: #{e.message}") @next_refresh = Time.now.utc + REFRESH_ON_ERROR end true end # ===================================================================== # Brand # ===================================================================== def brand_name config.dig('brand', 'name') || 'DocuSeal' end def brand_short_name config.dig('brand', 'short_name') || brand_name end def tagline config.dig('brand', 'tagline') || '' end def description config.dig('brand', 'description') || '' end def page_title(signed_in: false) key = signed_in ? 'page_title_signed_in' : 'page_title_signed_out' config.dig('brand', key) || brand_name end # ===================================================================== # URLs # ===================================================================== def website_url config.dig('urls', 'website') || 'https://www.docuseal.com' end def support_email config.dig('urls', 'support_email') || 'support@docuseal.com' end def privacy_policy_url config.dig('urls', 'privacy_policy') end def terms_url config.dig('urls', 'terms_of_service') end def twitter_url config.dig('urls', 'twitter_url') end def twitter_handle config.dig('urls', 'twitter_handle') end def github_url config.dig('urls', 'github_url') end def discord_url config.dig('urls', 'discord_url') end # ===================================================================== # Email # ===================================================================== def email_from name = config.dig('email', 'from_name') || brand_name addr = config.dig('email', 'from_address') || support_email "#{name} <#{addr}>" end def email_attribution_html raw = config.dig('email', 'attribution_html') || 'Sent with %{brand}.' raw.gsub('%{brand}', brand_name).gsub('%{website}', website_url) end # ===================================================================== # Assets # ===================================================================== def logo_path config.dig('assets', 'logo_path') || '/logo.svg' end def logo_width config.dig('assets', 'logo_width') || 37 end def logo_height config.dig('assets', 'logo_height') || 37 end def favicon_svg config.dig('assets', 'favicon_svg') || '/favicon.svg' end def favicon_ico config.dig('assets', 'favicon_ico') || '/favicon.ico' end def favicon_16 config.dig('assets', 'favicon_16') || '/favicon-16x16.png' end def favicon_32 config.dig('assets', 'favicon_32') || '/favicon-32x32.png' end def favicon_96 config.dig('assets', 'favicon_96') || '/favicon-96x96.png' end def apple_touch_icon config.dig('assets', 'apple_touch_icon') || '/apple-icon-180x180.png' end def preview_image config.dig('assets', 'preview_image') || '/preview.png' end # ===================================================================== # Theme — HSL triplets for DaisyUI / CSS custom properties # ===================================================================== def theme(key) config.dig('theme', key.to_s) || THEME_DEFAULTS[key.to_s] || '0 0% 50%' end # ===================================================================== # PDF / Audit trail # ===================================================================== def sign_reason(name) template = config.dig('pdf', 'sign_reason') || 'Signed by %{name}' template.gsub('%{name}', name.to_s) end def audit_trail_footer config.dig('pdf', 'audit_trail_footer') || "Signed with #{brand_name}" end def pdf_creator creator = config.dig('pdf', 'creator') || brand_name "#{creator} (#{website_url})" end def cert_name config.dig('pdf', 'cert_name') || 'docuseal_aatl' end # ===================================================================== # PWA # ===================================================================== def pwa_description config.dig('pwa', 'description') || description end def pwa_theme_color config.dig('pwa', 'theme_color') || '#FFFFFF' end def pwa_background_color config.dig('pwa', 'background_color') || '#FFFFFF' end # ===================================================================== # Webhooks # ===================================================================== def webhook_user_agent config.dig('webhooks', 'user_agent') || "#{brand_name} Webhook" end # ===================================================================== # Feature flags # ===================================================================== def show_github_button? dig_bool('features', 'show_github_button', false) end def show_powered_by? dig_bool('features', 'show_powered_by', false) end def powered_by_text config.dig('features', 'powered_by_text') || brand_name end def show_ai_link? dig_bool('features', 'show_ai_link', false) end def show_discord_link? dig_bool('features', 'show_discord_link', false) end def show_pro_upsells? dig_bool('features', 'show_pro_upsells', false) end # ===================================================================== # Internal # ===================================================================== def temp_email_domain config.dig('internal', 'temp_email_domain') || 'docuseal.com' end # ===================================================================== # Locale / Translations # ===================================================================== def default_locale config.dig('locale', 'default') || 'en' end def available_locales config.dig('locale', 'available') || %w[en] end def fallback_locale config.dig('locale', 'fallback') || 'en' end def translation_overrides config.dig('text', 'translations') || {} end # ===================================================================== # Styling # ===================================================================== def styling_variables DEFAULT_STYLING_VARIABLES.merge(config.dig('styling', 'css_variables') || {}) end def inline_css_variables vars = { 'wl-ib-primary' => theme(:primary), 'wl-ib-primary-strong' => theme(:primary_focus), 'wl-ib-primary-soft' => "#{theme(:primary)} / 0.12", 'wl-ib-neutral' => theme(:neutral), 'wl-ib-neutral-soft' => theme(:base_200), 'wl-p' => theme(:primary), 'wl-pf' => theme(:primary_focus), 'wl-pc' => theme(:primary_content), 'wl-s' => theme(:secondary), 'wl-sf' => theme(:secondary_focus), 'wl-sc' => theme(:secondary_content), 'wl-a' => theme(:accent), 'wl-af' => theme(:accent_focus), 'wl-ac' => theme(:accent_content), 'wl-n' => theme(:neutral), 'wl-nf' => theme(:neutral_focus), 'wl-nc' => theme(:neutral_content), 'wl-b1' => theme(:base_100), 'wl-b2' => theme(:base_200), 'wl-b3' => theme(:base_300), 'wl-bc' => theme(:base_content), 'wl-in' => theme(:info), 'wl-su' => theme(:success), 'wl-wa' => theme(:warning), 'wl-er' => theme(:error), 'wl-rounded-btn' => theme(:rounded_btn) } styling_variables.each { |key, value| vars["wl-#{key}"] = value } declarations = vars.map { |k, v| "--#{k}: #{v};" }.join(' ') ":root { #{declarations} }" end # ===================================================================== # Config signature (file-based only) # ===================================================================== def enforce_config_signature? dig_bool('security', 'enforce_config_signature', false) end def config_signature config.dig('security', 'config_signature') || '' end def signature_payload canonical_payload(config) end def generate_config_signature(secret) raise ConfigError, 'Secret required' if secret.to_s.empty? OpenSSL::HMAC.hexdigest('SHA256', secret, signature_payload).downcase end private # ===================================================================== # Config loading # ===================================================================== def load_config! @mutex.synchronize do return @config if @config # another thread beat us if CONFIG_PATH.file? load_from_file! elsif Rails.env.test? load_test_defaults! else load_from_api! end end @config end def load_from_file! raw = YAML.safe_load_file( CONFIG_PATH, permitted_classes: [], permitted_symbols: [], aliases: false ) raise ConfigError, '[Whitelabel] Config must be a YAML mapping' unless raw.is_a?(Hash) verify_file_signature!(raw) @config = raw @api_sourced = false Rails.logger.info("[Whitelabel] Loaded config from file: #{CONFIG_PATH}") rescue Psych::SyntaxError => e raise ConfigError, "[Whitelabel] YAML parse error in #{CONFIG_PATH}: #{e.message}" rescue Errno::EISDIR raise ConfigError, "[Whitelabel] #{CONFIG_PATH} is a directory, not a file." end def load_from_api! licence_key = ENV['INTEBEC_LICENCE_KEY'].to_s secret_key = ENV['INTEBEC_SECRET_KEY'].to_s if licence_key.empty? || secret_key.empty? raise ConfigError, '[Whitelabel] No config file found and INTEBEC_LICENCE_KEY / INTEBEC_SECRET_KEY ' \ 'env vars are missing. Cannot start without a config source.' end @config = fetch_remote_config @api_sourced = true @next_refresh = Time.now.utc + REFRESH_INTERVAL Rails.logger.info('[Whitelabel] Loaded config from Intebec Dashboard API') end def load_test_defaults! @config = {} @api_sourced = false Rails.logger.info('[Whitelabel] Test mode — all accessors return safe fallbacks') end # ===================================================================== # Remote config fetch (with retry) # ===================================================================== def fetch_remote_config licence_key = ENV.fetch('INTEBEC_LICENCE_KEY') secret_key = ENV.fetch('INTEBEC_SECRET_KEY') last_error = nil API_MAX_RETRIES.times do |attempt| uri = URI.join(DASHBOARD_URL, CONFIG_ENDPOINT) timestamp = Time.now.utc.to_i.to_s nonce = SecureRandom.hex(12) instance_id = stable_instance_id payload = [licence_key, timestamp, nonce, instance_id].join('.') signature = OpenSSL::HMAC.hexdigest('SHA256', secret_key, payload) uri.query = URI.encode_www_form(licence_key: licence_key, instance_id: instance_id) req = Net::HTTP::Get.new(uri) req['Accept'] = 'application/json' req['X-Licence-Key'] = licence_key req['X-Licence-Timestamp'] = timestamp req['X-Licence-Nonce'] = nonce req['X-Licence-Signature'] = signature req['X-Licence-Instance'] = instance_id req['User-Agent'] = 'Intebec-DocuSeal' resp = Net::HTTP.start( uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: API_TIMEOUT, read_timeout: API_TIMEOUT ) { |http| http.request(req) } unless [200, 201].include?(resp.code.to_i) raise ConfigError, "HTTP #{resp.code}" end parsed = JSON.parse(resp.body) status = parsed['status'].to_s unless %w[active trial].include?(status) raise LicenceRevokedError, "Licence status: #{status}" end remote_cfg = parsed['config'] raise ConfigError, 'API returned no config payload' unless remote_cfg.is_a?(Hash) return remote_cfg rescue LicenceRevokedError raise # don't retry revocations rescue StandardError => e last_error = e.message delay = API_RETRY_DELAY * (2**attempt) if attempt < API_MAX_RETRIES - 1 Rails.logger.warn( "[Whitelabel] API attempt #{attempt + 1}/#{API_MAX_RETRIES} " \ "failed: #{e.message}, retry in #{delay}s" ) sleep(delay) end end raise ConfigError, "[Whitelabel] Dashboard unreachable after #{API_MAX_RETRIES} attempts: #{last_error}" end def stable_instance_id @stable_instance_id ||= begin raw = [ENV.fetch('INTEBEC_LICENCE_KEY', ''), ENV.fetch('HOST', 'localhost')].join(':') OpenSSL::Digest::SHA256.hexdigest(raw) end end # ===================================================================== # File signature verification (optional, for file-based configs) # ===================================================================== def verify_file_signature!(raw) return unless raw.dig('security', 'enforce_config_signature') == true secret = ENV['INTEBEC_SECRET_KEY'].to_s raise ConfigError, '[Whitelabel] INTEBEC_SECRET_KEY required for config signature verification' if secret.empty? expected = raw.dig('security', 'config_signature').to_s.downcase actual = OpenSSL::HMAC.hexdigest('SHA256', secret, canonical_payload(raw)).downcase unless expected.length == 64 && secure_compare(actual, expected) raise ConfigError, '[Whitelabel] Config signature mismatch — refusing to boot.' end end def canonical_payload(loaded) copy = Marshal.load(Marshal.dump(loaded)) copy['security']&.delete('config_signature') JSON.generate(deep_sort_hash(copy)) end def deep_sort_hash(value) case value when Hash value.keys.sort.each_with_object({}) { |k, h| h[k] = deep_sort_hash(value[k]) } when Array value.map { |v| deep_sort_hash(v) } else value end end def secure_compare(a, b) return false unless a.bytesize == b.bytesize ActiveSupport::SecurityUtils.secure_compare(a, b) end # ===================================================================== # Helpers # ===================================================================== def dig_bool(section, key, default = false) value = config.dig(section, key) value.nil? ? default : value end end end