@ -1,43 +1,137 @@
# frozen_string_literal: true
# =============================================================================
# Whitelabel — Centralised brand config uration loader
# Whitelabel — Centralised brand config + licence enforcement
# =============================================================================
# Reads config/whitelabel.yml once at boot and exposes every value through
# simple accessor methods. All view helpers, mailers, PDF generators and
# other call-sites should use Whitelabel.xxx instead of hard-coding strings.
#
# Usage examples:
# Whitelabel.brand_name # => "Intébec"
# Whitelabel.support_email # => "support@intebec.ca"
# Whitelabel.theme(:primary) # => "216 77% 52%"
# Whitelabel.email_from # => "Intébec <info@intebec.ca>"
# Whitelabel.sign_reason("John") # => "Signed by John with Intébec"
# 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
#
# After editing config/whitelabel.yml, call Whitelabel.reload! or restart.
# 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
CONFIG_PATH = Rails . root . join ( 'config' , 'whitelabel.yml' ) . freeze
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 loader
# -----------------------------------------------------------------------
# =====================================================================
# Core
# =====================================================================
def config
@config || = load_config
@config || load_config !
end
def reload!
@config = load_config
@mutex . synchronize { @config = nil }
load_config!
end
def config_source
return :api if @api_sourced
return :test if @config && ! CONFIG_PATH . file?
:file
end
# -----------------------------------------------------------------------
# Brand identity
# -----------------------------------------------------------------------
# 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'
@ -60,16 +154,16 @@ module Whitelabel
config . dig ( 'brand' , key ) || brand_name
end
# -----------------------------------------------------------------------
# =====================================================================
# URLs
# -----------------------------------------------------------------------
# =====================================================================
def website_url
config . dig ( 'urls' , 'website' ) || 'https:// intebec.ca '
config . dig ( 'urls' , 'website' ) || 'https:// www.docuseal.com '
end
def support_email
config . dig ( 'urls' , 'support_email' ) || 'support@ intebec.ca '
config . dig ( 'urls' , 'support_email' ) || 'support@ docuseal.com '
end
def privacy_policy_url
@ -96,9 +190,9 @@ module Whitelabel
config . dig ( 'urls' , 'discord_url' )
end
# -----------------------------------------------------------------------
# =====================================================================
# Email
# -----------------------------------------------------------------------
# =====================================================================
def email_from
name = config . dig ( 'email' , 'from_name' ) || brand_name
@ -107,13 +201,14 @@ module Whitelabel
end
def email_attribution_html
raw = config . dig ( 'email' , 'attribution_html' ) || ''
raw = config . dig ( 'email' , 'attribution_html' ) ||
'Sent with <a href="%{website}">%{brand}</a>.'
raw . gsub ( '%{brand}' , brand_name ) . gsub ( '%{website}' , website_url )
end
# -----------------------------------------------------------------------
# =====================================================================
# Assets
# -----------------------------------------------------------------------
# =====================================================================
def logo_path
config . dig ( 'assets' , 'logo_path' ) || '/logo.svg'
@ -155,20 +250,20 @@ module Whitelabel
config . dig ( 'assets' , 'preview_image' ) || '/preview.png'
end
# -----------------------------------------------------------------------
# Theme — returns HSL triplets for DaisyUI / CSS custom properties
# -----------------------------------------------------------------------
# =====================================================================
# Theme — HSL triplets for DaisyUI / CSS custom properties
# =====================================================================
def theme ( key )
config . dig ( 'theme' , key . to_s )
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} with #{ brand_name } "
template = config . dig ( 'pdf' , 'sign_reason' ) || 'Signed by %{name}'
template . gsub ( '%{name}' , name . to_s )
end
@ -182,43 +277,43 @@ module Whitelabel
end
def cert_name
config . dig ( 'pdf' , 'cert_name' ) || " #{ brand_name } Self-Host Autogenerated "
config . dig ( 'pdf' , 'cert_name' ) || 'docuseal_aatl'
end
# -----------------------------------------------------------------------
# =====================================================================
# PWA
# -----------------------------------------------------------------------
# =====================================================================
def pwa_description
config . dig ( 'pwa' , 'description' ) || " #{ brand_name } is a secure platform for digital document signing. "
config . dig ( 'pwa' , 'description' ) || description
end
def pwa_theme_color
config . dig ( 'pwa' , 'theme_color' ) || '#F AF7F4 '
config . dig ( 'pwa' , 'theme_color' ) || '#F FFFFF '
end
def pwa_background_color
config . dig ( 'pwa' , 'background_color' ) || '#F AF7F4 '
config . dig ( 'pwa' , 'background_color' ) || '#F FFFFF '
end
# -----------------------------------------------------------------------
# =====================================================================
# Webhooks
# -----------------------------------------------------------------------
# =====================================================================
def webhook_user_agent
config . dig ( 'webhooks' , 'user_agent' ) || " #{ brand_name } Webhook "
end
# -----------------------------------------------------------------------
# =====================================================================
# Feature flags
# -----------------------------------------------------------------------
# =====================================================================
def show_github_button?
config. dig( 'features' , 'show_github_button' ) == true
dig_bool ( 'features' , 'show_github_button' , false )
end
def show_powered_by?
config. dig( 'features' , 'show_powered_by' ) != false
dig_bool ( 'features' , 'show_powered_by' , false )
end
def powered_by_text
@ -226,34 +321,296 @@ module Whitelabel
end
def show_ai_link?
config. dig( 'features' , 'show_ai_link' ) == true
dig_bool ( 'features' , 'show_ai_link' , false )
end
def show_discord_link?
config. dig( 'features' , 'show_discord_link' ) == true
dig_bool ( 'features' , 'show_discord_link' , false )
end
def show_pro_upsells?
config. dig( 'features' , 'show_pro_upsells' ) == true
dig_bool ( 'features' , 'show_pro_upsells' , false )
end
# -----------------------------------------------------------------------
# Internal / technical
# -----------------------------------------------------------------------
# =====================================================================
# Internal
# =====================================================================
def temp_email_domain
config . dig ( 'internal' , 'temp_email_domain' ) || ' ex amp le .com'
config . dig ( 'internal' , 'temp_email_domain' ) || ' docus eal.com'
end
private
# =====================================================================
# 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 load_config
return { } unless CONFIG_PATH . exist?
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
YAML . safe_load_file ( CONFIG_PATH , permitted_classes : [ Symbol ] ) || { }
# =====================================================================
# 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
Rails . logger . error ( " [Whitelabel] Failed to parse #{ CONFIG_PATH } : #{ e . message } " )
{ }
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