# etc/defaults/config.defaults.yaml --- site: host: <%= ENV['HOST'] || 'localhost:3000' %> # Turns https/http on or off when generating links ssl: <%= ENV['SSL'] == 'true' || false %> # IMPORTANT: After setting the secret, it should not be changed. # Be sure to create and store a backup in a secure offsite # location. Changing the secret can lead to unforeseen issues # like not being able to decrypt existing secrets. # See docs/runbooks/secret-rotation.md before touching it. secret: <%= ENV['SECRET'] || nil %> # Boot-time SECRET verifier (QS-6). The app stores an HKDF-derived # verifier in the datastore and checks it on every boot, so a changed # SECRET (or a datastore from another install) is detected instead of # silently making existing secrets undecryptable. # warn - log loudly on mismatch, keep booting (default) # enforce - refuse to boot on mismatch # off - skip the check (escape hatch for multi-tenant datastores) secret_verifier_mode: <%= ENV['SECRET_VERIFIER_MODE'] || 'warn' %> # API and UI Configuration interface: ui: # Controls whether the web interface is enabled # When false, only a basic explanation page is shown enabled: <%= ENV['UI_ENABLED'] != 'false' %> # Homepage Mode # # Determines which homepage experience to show based on the request # headers. It can change the content presented to a visitor when they # navigate to the homepage but it does not expand or restrict access. # # ✓ Can do: Hide the Create Secret form for external users # ✗ Cannot do: Grant access to endpoints for creating secret links # # This does not protect API endpoints and has no effect on existing # authentication or authorization logic. # # Detection Methods (evaluated in order): # 1. matching_cidrs - Client IP must match one of these subnets # 2. mode_header - Fallback if no CIDR match (requires header value = mode) # # Example: # mode: internal # matching_cidrs: # - 203.0.113.0/24 # Office network # - 198.51.100.0/24 # Partner network # - 192.0.2.0/24 # Additional location # # Example: # mode: external # mode_header: O-Homepage-Mode # # Privacy Preservation # # CIDR Subnet Limitations: # - Minimum prefix length: /24 for IPv4 (supports any prefix up to /24, e.g., /8, /12, /16, /24) # - Minimum prefix length: /48 for IPv6 (supports any prefix up to /48) # - Prefix lengths greater than minimum are not supported for privacy # # Proxy Configuration: # The client IP used for CIDR matching is resolved by Rack::Request#ip, # configured globally under site.network.trusted_proxy (see below). # Homepage mode no longer carries its own proxy-depth settings. # homepage: mode: <%= ENV['UI_HOMEPAGE_MODE'] || nil %> matching_cidrs: <%= ENV['UI_HOMEPAGE_MATCHING_CIDRS']&.split(',')&.map(&:strip) || [] %> mode_header: <%= ENV['UI_HOMEPAGE_MODE_HEADER'] || 'O-Homepage-Mode' %> # Deployment-wide default landing page for the CANONICAL site when the # homepage secret form is gated by auth (auth.required or mode=external). # One of: closed (quiet two-tagline, no CTA), minimal, or v1. Per-domain # config and the ?variant= URL override take precedence; blank uses the # frontend default (closed). Applies to the canonical site only — # custom domains use custom_disabled_variant below. disabled_variant: <%= ENV['DEFAULT_DISABLED_HOMEPAGE_VARIANT'] || nil %> # Deployment-wide default landing page for CUSTOM DOMAINS whose homepage # is private (homepage disabled, or incoming mode degraded at runtime). # Same values as disabled_variant (closed / minimal / v1). Kept separate # so the canonical and custom-domain defaults stay decoupled: a custom # domain does NOT inherit disabled_variant. Per-domain config # (homepage_config.disabled_homepage_variant) and the ?variant= URL # override take precedence; blank uses the frontend default (closed). custom_disabled_variant: <%= ENV['DEFAULT_CUSTOM_DOMAIN_DISABLED_HOMEPAGE_VARIANT'] || nil %> # Links rendered when a public visitor lands on the homepage but # the secret form is gated by auth (e.g. mode=external). Recipients # arriving via a shared link use these to learn about the service. public_links: # URL the "What is this link?" affordance points at. When empty, # the affordance is hidden in the disabled-homepage view. recipient_intro: <%= ENV['HOMEPAGE_PUBLIC_LINKS_RECIPIENT_INTRO'] || nil %> # Form field visibility flags # Controls which optional fields appear on the secret creation form. # All default to true (visible). Set to 'false' to hide from the UI. # These are independent of API guest_routes, which gate endpoint access. capabilities: burn: <%= ENV['UI_CAPABILITIES_BURN'] != 'false' %> show: <%= ENV['UI_CAPABILITIES_SHOW'] != 'false' %> receipt: <%= ENV['UI_CAPABILITIES_RECEIPT'] != 'false' %> recipient: <%= ENV['UI_CAPABILITIES_RECIPIENT'] != 'false' %> # Header configuration # Controls the site header and masthead layout. Brand identity — which # logo asset renders and what the product is called — lives in the # brand: block below (BRAND_LOGO_URL, BRAND_PRODUCT_NAME, BRAND_LOGO_ALT); # the knobs here only control how the masthead presents it. The former # header.branding nesting (LOGO_URL, LOGO_ALT, SITE_NAME) is deprecated # and honored as a fallback by Config#normalize_brand (#3612). header: # Control switch to enable/disable header customization enabled: <%= ENV['HEADER_ENABLED'] != 'false' %> # Masthead layout knobs (presentation only). All three treat an unset # OR empty env var as "not specified" (YAML nil) so compose files with # blank assignments don't accidentally pin an explicit value. href is # JSON-quoted for the same reason as the brand block below: an # operator URL with YAML-significant characters must survive parsing. logo: # Where the logo lockup links to. Unset uses '/'. href: <%= (v = ENV['LOGO_LINK'].to_s).empty? ? nil : v.to_json %> # Show the product name as a text mark next to the logo icon. # Unset (default): shown, unless a custom brand logo is configured # (custom logos usually embed their own wordmark). Set true/false # to force either way. The product name itself comes from # BRAND_PRODUCT_NAME and remains active in page titles, MFA labels, # and emails regardless of this knob. show_name: <%= (v = ENV['LOGO_SHOW_NAME'].to_s).empty? ? nil : v != 'false' %> # Render the logo at a larger size in the authenticated header. # Useful for rasterized brand assets (e.g. BRAND_LOGO_URL=/img/brand.png) # that need more visual presence alongside the org/domain switchers. # The default (unset/false) keeps the logo compact (40px) so context # switchers fit on the same row. When true, custom logos render at # 80px in authenticated views. Unauthenticated views (branded # homepage / disabled page) always render the logo prominently. prominent: <%= (v = ENV['LOGO_PROMINENT'].to_s).empty? ? nil : v == 'true' %> # Navigation configuration navigation: # Enable/disable header navigation entirely enabled: <%= ENV['HEADER_NAV_ENABLED'] != 'false' %> # Footer link configuration (public pages) # These links appear in the footer of each page footer_links: # Global toggle to enable/disable all footer links enabled: <%= ENV['FOOTER_LINKS'] == 'true' %> # Organized groups of links groups: - name: legal i18n_key: web.footer.legals links: - text: Terms of Service i18n_key: web.layout.terms_of_service # Replace with your own terms URL or use relative path like /terms url: <%= ENV['TERMS_URL'] %> - text: Privacy Policy i18n_key: web.layout.privacy_policy # Replace with your own privacy URL or use relative path like /privacy url: <%= ENV['PRIVACY_URL'] %> - name: resources i18n_key: web.footer.resources links: - text: Docs i18n_key: web.footer.docs # Replace with your documentation URL url: <%= ENV['DOCS_URL'] || 'https://docs.onetimesecret.com/' %> - text: Status i18n_key: web.COMMON.status # Replace with your status page URL if you have one url: <%= ENV['STATUS_URL'] || 'https://status.onetimesecret.com/' %> - name: support i18n_key: web.footer.support links: - text: About i18n_key: web.COMMON.about # Replace with your about page URL url: <%= ENV['ABOUT_URL'] || 'https://onetimesecret.com/about' %> - text: Contact i18n_key: web.footer.contact url: <%= ENV['CONTACT_URL'] || '/feedback' %> # Workspace footer links (authenticated users only) # Separate from footer_links which are for public pages workspace_links: # Global toggle to enable/disable all footer links enabled: <%= ENV['WORKSPACE_LINKS'] == 'true' %> links: - text: API Docs i18n_key: web.footer.api_docs url: <%= ENV['WORKSPACE_API_DOCS_URL'] || 'https://api.onetimesecret.com/' %> - text: Branding Guide i18n_key: web.footer.branding_guide url: <%= ENV['WORKSPACE_BRANDING_GUIDE_URL'] || 'https://docs.onetimesecret.com/' %> <% if ENV['WORKSPACE_FEEDBACK_ENABLED'] != 'false' %> - text: Feedback i18n_key: web.TITLES.feedback url: <%= ENV['WORKSPACE_FEEDBACK_URL'] || '/feedback' %> <% end %> # Show or hide the version number in the workspace footer show_version: <%= ENV['FOOTER_VERSION_ENABLED'] != 'false' %> # Show or hide the "Need help?" modal trigger on secret pages help: enabled: <%= ENV['HELP_ENABLED'] != 'false' %> # Controls whether the API endpoints are available. When disabled, the API # is completely disabled. Requests to /api/* will return 404. api: enabled: <%= ENV['API_ENABLED'] != 'false' %> # Guest API routes for anonymous access # When enabled, /api/v3/guest/* endpoints allow unauthenticated requests guest_routes: # Global toggle - disables all guest routes when false enabled: <%= ENV['API_GUEST_ROUTES_ENABLED'] != 'false' %> # Fine-grained controls (checked only when enabled=true) conceal: <%= ENV['API_GUEST_CONCEAL'] != 'false' %> generate: <%= ENV['API_GUEST_GENERATE'] != 'false' %> reveal: <%= ENV['API_GUEST_REVEAL'] != 'false' %> burn: <%= ENV['API_GUEST_BURN'] != 'false' %> show: <%= ENV['API_GUEST_SHOW'] != 'false' %> receipt: <%= ENV['API_GUEST_RECEIPT'] != 'false' %> # Configuration options for secret management secret_options: # Default Time-To-Live (TTL) for secrets in seconds # This value is used if no specific TTL is provided when creating a secret default_ttl: <%= ENV['DEFAULT_TTL'] || nil %> # Available TTL options for secret creation (in seconds) # These options will be presented to users when they create a new secret # Format: String of integers representing seconds ttl_options: <%= (ENV['TTL_OPTIONS'] || nil) %> # Maximum TTL for secrets created without an account (anonymous callers). # Default: 604800 (7 days) — a sane default, not a hard limit. Self-hosted # deployments may raise or lower it; the effective ceiling is the lower of # this value and the ttl_options maximum, bounded by 365 days. When billing # is enabled the free-tier plan limit applies as well, so the anonymous # grant never exceeds what an authenticated free-tier user receives. # # PLAN_TTL_ANONYMOUS is the deprecated alias, read only here — this line is # the single place the old name survives. The ceiling itself is read from # the resolved config key (site.secret_options.ttl_max_anonymous) via # WithEntitlements.configured_anonymous_max_ttl, never from the env var. # Organization.free_tier_limits does read ENV['TTL_MAX_ANONYMOUS'] directly # for its secret_lifetime fallback, and does not honour the alias. ttl_max_anonymous: <%= ENV['TTL_MAX_ANONYMOUS'] || ENV['PLAN_TTL_ANONYMOUS'] || nil %> # Settings for the passphrase field that protects access to secrets passphrase: # Require users to enter a passphrase when creating secrets required: <%= ENV['PASSPHRASE_REQUIRED'] == 'true' || false %> # Minimum number of characters required for passphrases # Default to 4 chars to prevent trivially guessable passphrases while # maintaining backward compatibility with existing integrations. minimum_length: <%= ENV['PASSPHRASE_MIN_LENGTH'] || 4 %> # Maximum number of characters allowed for passphrases maximum_length: <%= ENV['PASSPHRASE_MAX_LENGTH'] || 128 %> # Enforce complexity requirements (uppercase, lowercase, numbers, symbols) enforce_complexity: <%= ENV['PASSPHRASE_ENFORCE_COMPLEXITY'] == 'true' || false %> # Limits on the secret content itself content: # Maximum number of characters allowed in a secret's body. Enforced # server-side on secret creation and used as the single source of # truth for the client-side textarea limit. maximum_length: <%= ENV['SECRET_MAX_LENGTH'] || 10000 %> # How long (in seconds) a generated password remains visible on the # receipt page after creation. Set to 0 to disable receipt-page display. generated_value_display_ttl: <%= ENV['GENERATED_VALUE_DISPLAY_TTL'] || 60 %> # Settings for password generation (when users click "Generate Password") password_generation: # Default length for generated passwords default_length: <%= ENV['PASSWORD_GEN_LENGTH'] || 12 %> # Server-enforced ceiling on requested generated-password length. # Rejects oversized `length` values before allocation (DoS guard); # matches the frontend Zod max so the two limits stay in lockstep. maximum_length: <%= ENV['PASSWORD_GEN_MAX_LENGTH'] || 128 %> # Character sets to include in generated passwords character_sets: # Include uppercase letters (A-Z) uppercase: <%= ENV['PASSWORD_GEN_UPPERCASE'] != 'false' %> # Include lowercase letters (a-z) lowercase: <%= ENV['PASSWORD_GEN_LOWERCASE'] != 'false' %> # Include numbers (0-9) numbers: <%= ENV['PASSWORD_GEN_NUMBERS'] != 'false' %> # Include symbols (!@#$%^&*()_+-=[]{}|;:,.<>?) symbols: <%= ENV['PASSWORD_GEN_SYMBOLS'] != 'false' %> # Exclude ambiguous characters (0, O, l, 1, I) to prevent confusion exclude_ambiguous: <%= ENV['PASSWORD_GEN_EXCLUDE_AMBIGUOUS'] != 'false' %> # Registration and Authentication settings authentication: # Can be disabled altogether, including API authentication. enabled: <%= ENV['AUTH_ENABLED'] != 'false' %> # Allow users to create accounts. This can be disabled if you plan # to create accounts manually or enable during setup when accounts # can be created and then disabled to prevent any new users from # creating accounts. signup: <%= ENV['AUTH_SIGNUP'] != 'false' %> # Generally if you allow registration, you allow signin. But there # are circumstances where it's helpful to turn off authentication # temporarily. signin: <%= ENV['AUTH_SIGNIN'] != 'false' %> # By default, new accounts need to verify their email address before # they can sign in. This is a security measure to prevent spamming # and abuse of the system. If you're running a private instance or # an instance for your team or company, you can disable this feature # to make it easier for users to sign in. autoverify: <%= ENV['AUTH_AUTOVERIFY'] == 'true' || false %> # When enabled, the homepage secret form is not available unless # the user is logged in. Similar to a disabled homepage, but still # shows the header with logo and navigation links. This allows for # a more restrictive mode where only authenticated users can create # secrets while maintaining site navigation and branding. required: <%= ENV['AUTH_REQUIRED'] == 'true' %> # Colonel (admin) accounts are managed via the CLI: # bin/ots customers role promote user@example.com # bin/ots customers role demote user@example.com # bin/ots customers role list # Restrict account creation to specific email domains while allowing # secret-sharing with any email address. When set, only email addresses # from the listed domains can create accounts. This is useful for # organizations that want to limit signups to their own domains while # still allowing secrets to be shared with anyone. # Format: List of domain names (e.g., example.com, company.org) # If empty or not set, signups are allowed from any domain (default). # Environment variable: ALLOWED_SIGNUP_DOMAIN (comma-separated list) allowed_signup_domains: <%= ENV['ALLOWED_SIGNUP_DOMAIN']&.split(',')&.map(&:strip) || [] %> # Rate limiting for reset-password requests (#3872, #3948). Applies in EVERY # auth mode — full mode enforces it in the Rodauth route hook, simple mode # in AccountAPI::Logic::Authentication::ResetPasswordRequest#raise_concerns. # POST /auth/reset-password-request is enumeration-safe (#3857) but keeps # an accepted timing residual; exploiting it needs many samples, so this # throttles requests per client IP (tight tier) and per submitted login # (higher backstop for IP-rotating callers) BEFORE any account lookup. # Enabled by default (protective); set RESET_REQUEST_RATE_LIMIT_ENABLED=false # to opt out. See lib/onetime/security/reset_request_rate_limiter.rb. reset_request_rate_limit: enabled: <%= ENV['RESET_REQUEST_RATE_LIMIT_ENABLED'] != 'false' %> # Requests permitted per window from a single client IP. max_per_ip: <%= ENV['RESET_REQUEST_RATE_LIMIT_MAX_PER_IP'] || 10 %> # Higher backstop cap per submitted login (catches IP rotation). max_per_email: <%= ENV['RESET_REQUEST_RATE_LIMIT_MAX_PER_EMAIL'] || 30 %> # Counting window in seconds (1 hour). window: <%= ENV['RESET_REQUEST_RATE_LIMIT_WINDOW'] || 3600 %> # Lockout duration in seconds once a tier's cap is hit (1 hour). lockout: <%= ENV['RESET_REQUEST_RATE_LIMIT_LOCKOUT'] || 3600 %> # Rate limiting for unauthenticated account creation (#3948, audit # 2026-07-30 finding #4). POST /auth/create-account previously had no # limiter in EITHER auth mode, leaving unthrottled account creation: one # Customer record (no TTL) plus one welcome email per distinct address, # with subaddressing folding many addresses onto one mailbox. Throttles per # client IP BEFORE any account lookup or write, in both modes: full mode # via the Rodauth before_create_account_route hook (apps/web/auth/config/ # hooks/create_account.rb), simple mode via # AccountAPI::Logic::Account::CreateAccount#raise_concerns. Single tier by # necessity, not choice — every request in the abuse pattern carries a # fresh address, so a per-email tier would cap nothing. # Enabled by default (protective); set # CREATE_ACCOUNT_RATE_LIMIT_ENABLED=false to opt out. # See lib/onetime/security/create_account_rate_limiter.rb. create_account_rate_limit: enabled: <%= ENV['CREATE_ACCOUNT_RATE_LIMIT_ENABLED'] != 'false' %> # Signups permitted per window from a single masked client IP. The bucket # is a whole /24 (office, campus NAT), and behind an unconfigured reverse # proxy it is the entire deployment — so this is deliberately loose # relative to the one signup a person needs. Raise it for dense-NAT # populations; a too-tight value here is a signup-funnel outage. max_per_ip: <%= ENV['CREATE_ACCOUNT_RATE_LIMIT_MAX_PER_IP'] || 10 %> # Counting window in seconds (1 hour). window: <%= ENV['CREATE_ACCOUNT_RATE_LIMIT_WINDOW'] || 3600 %> # Lockout duration in seconds once the cap is hit (1 hour). lockout: <%= ENV['CREATE_ACCOUNT_RATE_LIMIT_LOCKOUT'] || 3600 %> # Links to documentation. For onetimesecret.com, this is # docs.onetimesecret.com. support: host: <%= ENV['SUPPORT_HOST'] || nil %> # Session configuration # Controls browser cookie and server-side session behavior. # Session handling is auth-mode agnostic (works with simple or full mode). session: # Session secret for HMAC signing. Falls back to site.secret if not set. secret: <%= ENV['SESSION_SECRET'] %> # Session lifetime in seconds (default: 24 hours) expire_after: 86400 # Cookie name key: 'onetime.session' # Require HTTPS for cookies (recommended: true in production) # OMIT the key entirely when SSL env is not 'true' so boot.rb's ssl_enabled? # fallback can default this to true in production / when site.ssl is on. # (Emitting `null` here fails schema validation — the contract is # boolean-or-absent, not boolean-or-null; see site.ts `secure`.) <% if ENV['SSL'] == 'true' %> secure: true <% end %> # SameSite cookie attribute (strict|lax|none) # Required for Stripe/OAuth redirects - 'strict' blocks cookies on cross-site GET navigations same_site: lax # Prevent JavaScript access to cookies (always true in Rack) httponly: true # Paths that must NOT mint or persist a session (#3997). # Anonymous probe endpoints polled by load balancers, uptime monitors and # orchestrators — none keep cookies, so every poll used to write a # session: key with the full expire_after TTL. # # Matching is EXACT string equality against the full external path # (SCRIPT_NAME + PATH_INFO), so these are written as the client sees them # even though the middleware runs per-mount inside Rack::URLMap. # # WARNING: never list /api/v*/secret/*/status here. Those are # capability-token data reads audited via SecretActivity, not probes. skip_paths: - /health - /health/advanced - /auth/health - /api/v1/status - /api/v2/status - /api/v3/status # Middleware Configuration # Controls which security and performance middleware components are enabled. # Each setting can be overridden via environment variables. middleware: # Serve static files for frontend vue application static_files: <%= ENV['MIDDLEWARE_STATIC_FILES'] != 'false' %> # Sanitizes request parameters to ensure proper UTF-8 encoding # Prevents encoding-based attacks and malformed input utf8_sanitizer: <%= ENV['MIDDLEWARE_UTF8_SANITIZER'] != 'false' %> # Protects against Cross-Site Request Forgery (CSRF) attacks # Validates that requests originate from the same site authenticity_token: <%= ENV['MIDDLEWARE_AUTHENTICITY_TOKEN'] != 'false' %> # Protects against Cross-Site Request Forgery (CSRF) attacks # Validates HTTP Origin header matches expected origin http_origin: <%= ENV['MIDDLEWARE_HTTP_ORIGIN'] == 'true' %> # Sets X-XSS-Protection header to enable browser XSS filtering # Modern browsers rely less on this as CSP becomes standard xss_header: <%= ENV['MIDDLEWARE_XSS_HEADER'] == 'true' %> # Prevents your site from being embedded in frames (clickjacking protection) # Sets X-Frame-Options header to SAMEORIGIN or DENY frame_options: <%= ENV['MIDDLEWARE_FRAME_OPTIONS'] != 'false' %> # Blocks directory traversal attacks using "../" in paths # Critical for preventing unauthorized file access path_traversal: <%= ENV['MIDDLEWARE_PATH_TRAVERSAL'] != 'false' %> # Protects against cookie tossing attacks # Prevents session fixation via manipulated cookies cookie_tossing: <%= ENV['MIDDLEWARE_COOKIE_TOSSING'] == 'true' %> # Prevents IP spoofing attacks by validating IP addresses # Useful when IP-based access controls are implemented ip_spoofing: <%= ENV['MIDDLEWARE_IP_SPOOFING'] == 'true' %> # Forces all connections to use HTTPS via HSTS headers # Disable only for development or when behind a secure proxy strict_transport: <%= ENV['MIDDLEWARE_STRICT_TRANSPORT'] != 'false' %> # Per-application middleware profiles. # Each profile section gates the components a declared middleware profile # mounts (lib/onetime/application/middleware_profile.rb). These toggles # are independent of the shared site.middleware.* toggles above, which # govern the main app's Security mount and carry different defaults. profiles: # Governs the auth app's /auth middleware stack. All keys default ON # so the stack is identical in every environment (dev/test/prod). authenticated_web: # Gzip response compression via Rack::Deflater deflater: <%= ENV['MIDDLEWARE_AUTH_DEFLATER'] != 'false' %> # Content-Security-Policy header via Rack::Protection content_security_policy: <%= ENV['MIDDLEWARE_AUTH_CONTENT_SECURITY_POLICY'] != 'false' %> # X-Frame-Options clickjacking protection frame_options: <%= ENV['MIDDLEWARE_AUTH_FRAME_OPTIONS'] != 'false' %> # Origin-based CSRF protection with a display-domain allow_if http_origin: <%= ENV['MIDDLEWARE_AUTH_HTTP_ORIGIN'] != 'false' %> # Rejects requests with disagreeing forwarded-IP headers ip_spoofing: <%= ENV['MIDDLEWARE_AUTH_IP_SPOOFING'] != 'false' %> # Normalizes "../" segments out of request paths path_traversal: <%= ENV['MIDDLEWARE_AUTH_PATH_TRAVERSAL'] != 'false' %> # Ties the session to stable client attributes session_hijacking: <%= ENV['MIDDLEWARE_AUTH_SESSION_HIJACKING'] != 'false' %> # Security Configuration # Additional security settings beyond middleware security: # Content Security Policy (CSP) Configuration # # Adds Content-Security-Policy headers to web responses. On by default. # - Development mode: Less restrictive headers to allow hot reloading # - Production mode: Strict headers with nonce-based script protection # # The nonce is available via `req.env['onetime.nonce']` for custom # script/style assets. Backend views add it automatically. # csp: enabled: <%= ENV['CSP_ENABLED'] != 'false' %> # Network Configuration # Settings for proxy trust, client IP resolution, and network-level behavior. network: # Trusted Proxy Configuration # # Controls how client IPs are resolved when behind reverse proxies # (Kubernetes ingress, cloud load balancers, CDNs, etc.). # # Affects all IP-based features: ban checks, session tracking, # audit logs, homepage-mode CIDR matching, and the Colonel # "Your Current IP" display. # # WARNING: Only enable when: # - The app is behind a trusted reverse proxy # - Direct client access is blocked by firewall # - The proxy strips/overwrites client-provided X-Forwarded-For headers # - Each proxy appends its own IP to the forwarding header # trusted_proxy: # Turns proxy-aware IP resolution on. The mode, cidrs, and depth # settings below do nothing while this is false. # # When false, forwarded headers are ignored and the client IP is # REMOTE_ADDR — the address that opened the connection. That is right # only when clients connect to the app directly: put a # proxy in front and REMOTE_ADDR is the proxy, so every request looks # like it came from one address. When true, the client IP is read out # of the forwarded headers using `mode` below. Behind a proxy you # control, you want true. Behind one you cannot configure to overwrite # X-Forwarded-For, set true and set `mode` to depth, which picks the # client by its position in the chain so a forged entry is never # reached. enabled: <%= ENV['TRUSTED_PROXY_ENABLED'] == 'true' %> # How to resolve client IP behind proxies: # # 'filter' (default): CIDR-walk. Otto::Utils.resolve_client_ip walks the # forwarded chain LEFT-TO-RIGHT and returns the first entry that is not # in the trusted-proxy set — i.e. the LEFTMOST non-proxy entry becomes # the client IP. The trusted set is the RFC1918/loopback/link-local # private ranges (always trusted in this mode) plus every range in # `cidrs` below. Works for most k8s/cloud deployments where the proxy # tier has enumerable internal addresses. Note: filter mode ignores # `header` below and reads the X-Forwarded-For family (X-Forwarded-For, # X-Real-IP, X-Client-IP). # # SECURITY — read before enabling: the edge proxy MUST OVERWRITE # X-Forwarded-For with the real peer address so the resolved client IP # is always proxy-attested: # nginx: proxy_set_header X-Forwarded-For $remote_addr; # Caddy: header_up X-Forwarded-For {remote_host} # Use 'depth' instead when you cannot make the edge overwrite: it counts # positions from the right, so a forged leftmost entry is never reached. # # 'depth': Position-based counting. Skip exactly N rightmost hops. # Use when: # - CDN has a public IP (not RFC1918) # - Variable proxy depth that filter can't handle # - You need deterministic hop selection regardless of IP class # - Your edge appends to X-Forwarded-For and you cannot change it # # When filter and depth differ: # - CDN with public IP: filter returns CDN IP (add to cidrs or use depth) # - Variable hop count: filter handles automatically; depth fails if wrong # - Forged leftmost entry: filter returns it (see SECURITY above); depth # ignores it, since only the position N-from-the-right is read # # Matched case-insensitively and canonicalized (like `header` below). The # set is closed: any other value falls back to filter — the safer mode — # and WARNs at boot naming the value, rather than failing the boot. The # 'filter' default here is documentation; the app applies the same default # itself, so an unset or programmatically assembled config behaves # identically. mode: <%= ENV['TRUSTED_PROXY_MODE'] || 'filter' %> # Depth mode only: which header to read the forwarding chain from. # The accepted set is closed — exactly one of: # - X-Forwarded-For: Most common (nginx, Caddy, HAProxy, AWS ALB/ELB, # Cloudflare, Fastly, most k8s ingresses) # - Forwarded: RFC 7239 standard; IPv6 addresses are bracketed, # e.g. for="[2001:db8::1]". HAProxy and Apache can emit it, but it # remains uncommon in practice # - Both: Try Forwarded first, fall back to X-Forwarded-For # # Matched case-insensitively and canonicalized; any other value raises # at boot instead of silently resolving from the wrong header. # # Vendor client-IP headers are NOT selectable — CF-Connecting-IP, # True-Client-IP and friends are never read for client-IP resolution, # and they carry a single address rather than a chain, so there are no # hops to count. (Vendor GEO headers such as CF-IPCountry are a separate # concern with its own resolution path; this setting does not affect # them.) If the edge only sets one of those, have it write the chain # into X-Forwarded-For. Filter mode ignores this setting and reads the # X-Forwarded-For family (X-Forwarded-For, X-Real-IP, X-Client-IP). header: <%= ENV['TRUSTED_PROXY_HEADER'] || 'X-Forwarded-For' %> # Filter mode only: Additional CIDR ranges to trust as proxies # beyond RFC1918 defaults. Use when your CDN or proxy has public IPs. # Example: ["203.0.113.0/24", "2001:db8::/32"] cidrs: <%= ENV['TRUSTED_PROXY_CIDRS']&.split(',')&.map(&:strip) || [] %> # Depth mode only: Number of proxy hops to skip from the right. # 1 = standard single reverse proxy (nginx/Caddy) # 2 = CDN → reverse proxy → app # No-proxy / direct connection: set enabled: false above rather # than depth: 0; the master switch falls back to REMOTE_ADDR. depth: <%= ENV['TRUSTED_PROXY_DEPTH']&.to_i || 1 %> # Country-level geo resolution (#3989). Otto resolves an ISO-3166-1 # alpha-2 country code into env['otto.privacy.geo_country'] (or '**' # unknown — never a guess), consumed by the login/MFA alert emails, the # Colonel session sidecar (geo_country), and — opt-in — the org Secret # Activity trail (see features.secret_activity.geo_country_enabled). Geo is # ON by default in Otto; nothing here is required to get country from a CDN. # # Trust model (IMPORTANT): # - Vendor GEO headers (Cloudflare CF-IPCountry, CloudFront, Fastly, # Akamai, Azure, Vercel, ...) are honored ONLY in FILTER mode with # trusted_proxy.enabled=true and the CDN's ranges in trusted_proxy.cidrs # (so the header is proven to come from the edge). Out of the box, or in # DEPTH mode, header geo is NOT trusted and country resolves to '**'. # - DEPTH mode never trusts geo headers; use db_path (below) or accept '**'. geo: # Optional app-level geo header, checked BEFORE the built-in vendor # headers. FILTER MODE ONLY — setting it under depth mode would trip # Otto's boot-time depth/geo_header conflict, so it is ignored there. # Leave blank to use only the built-in vendor headers (e.g. CF-IPCountry). header: <%= ENV['GEO_HEADER'] %> # Optional path to a local MaxMind-format country database (.mmdb) for # deployments without a geo-tagging CDN (direct-connect, or depth mode). # Looked up on the ALREADY-MASKED IP, so raw addresses never reach it. # Works in ALL modes. Requires the optional 'maxmind-db' gem; a bad path # or missing gem fails at boot (not per-request). Blank = disabled. db_path: <%= ENV['GEO_DB_PATH'] %> # Assume HTTPS at the origin (opt-in, INDEPENDENT of trusted_proxy). # # Upgrade-only: when true, requests that do not already look like HTTPS # are marked as HTTPS before any downstream consumer reads the scheme # (Secure session cookie, HttpOrigin, CSRF, HSTS, scheme redirects). # # Only enable when a real TLS-terminating proxy fronts the origin AND it # does NOT forward X-Forwarded-Proto: https (e.g. Cloudflare Tunnel). # Standard nginx/Caddy/ALB setups forward the scheme and do NOT need # this. Never enable on a directly-reachable origin: it would let a # plain-HTTP client be treated as HTTPS. Also keep site.ssl at its https # default when this is on: pairing assume_https with site.ssl:false makes # config-driven share/email/redirect links emit http:// while clients use # https (mixed-content downgrade of generated URLs). assume_https: <%= ENV['ASSUME_HTTPS'] == 'true' %> # Admin (Colonel) Configuration # Host and network posture for the Colonel admin surfaces. The two factors # below are INDEPENDENT: a request must pass every gate that is active, and # neither replaces the other. admin: # Which HOSTNAMES serve the Colonel admin surfaces (/colonel shell and # /api/colonel API). A request whose validated detected host is not on this # list receives the same 404 as the CIDR gate below. # # Unset (default): ENFORCED, anchored on the canonical hosts — # features.domains.default and site.host, each with its www. variant. A # stock canonical deployment sees no change; tenant custom domains and # operator link-pool domains (features.domains.link_domains) stop serving # the admin console, which is the point. # # Set but BLANK (ADMIN_ALLOWED_HOSTS="" or only whitespace/commas): the # same anchor fallback as unset at runtime, plus a boot WARN (#4127) — the # operator wrote an allowlist that names nothing, and on a localhost or # bare-IP install that written config yields no host gate at all (the # anchors have nothing to anchor on, so the gate self-disables below). # # Set to serve admin on its own hostname (recommended; pair it with an edge # rule that 404s /colonel and /api/colonel on every OTHER vhost). Explicit # entries are matched LITERALLY — list the www. form too if you need it. # # ADMIN_ALLOWED_HOSTS=admin.example.com # # "*" ANYWHERE in the list disables the host gate entirely and logs a WARN # at boot; any other entry beside it is ignored (and named in a second # WARN). The CIDR gate is unaffected. This is the escape hatch for installs # reached by bare IP or by a hostname that is not the canonical domain. # # Matching is case-insensitive, port-stripped and trailing-dot-stripped. # ASCII/A-label only: there is no IDN library in this project, so an # internationalized domain must be given in its punycode (xn--) form. # # UNUSABLE ENTRIES — a wildcard pattern (*.example.com), a non-ASCII name, # a localhost form, or an IP literal — can never match a detected host. # Each is dropped and named in a boot WARN. If NOTHING in an explicitly set # list survives, the gate stays ACTIVE with an EMPTY allowlist: /colonel and # /api/colonel 404 on every hostname, rather than the admin console being # served on every hostname. The operator asked to restrict it, and a typo # must not produce the opposite. Onetime::Config.check_admin_allowed_hosts # says so at boot (WARN); it does NOT abort the boot, because the surfaces # are already fail-closed and the rest of the app is innocent. # Set "*" to turn the gate off on purpose. # # A forwarded host header (X-Forwarded-Host, Apx-Incoming-Host, # X-Original-Host, Forwarded) only counts toward this gate when # site.network.trusted_proxy is configured AND the peer passed it. Behind a # proxy that forwards the public hostname instead of rewriting Host, set # trusted_proxy or both admin surfaces 404 (see # docs/operations/admin-network-isolation.md). # # INACTIVE only when this list is UNSET and neither canonical anchor is a # hostname the app could ever detect — the stock local-dev and bare-IP # self-hosted posture (site.host defaults to localhost:3000). The gate # self-disables and logs a WARN at boot rather than locking the admin # surfaces out of installs that have no routable hostname to anchor on. # This exemption never applies to a list an operator set (above). # # Boot logs the effective posture of both factors on one INFO line, "Admin # surface isolation posture", including the site.network.trusted_proxy # state — with no trusted proxy declared, both gates judge inputs any peer # on a private address can influence. # # NO `|| []` FALLBACK HERE — deliberate (#4127). Unset must render nil and # a set-but-blank value (ADMIN_ALLOWED_HOSTS="" or whitespace/commas only) # must render a list with nothing in it, because # Onetime::Config.check_admin_allowed_hosts WARNs at boot on the second # shape and stays silent on the first. Folding both to [] here made an # explicitly written blank allowlist indistinguishable from no allowlist # at all. (DEFAULTS in lib/onetime/config.rb carries no allowed_hosts key # for the same reason — deep_merge would resolve nil back to it.) allowed_hosts: <%= ENV['ADMIN_ALLOWED_HOSTS']&.split(',')&.map(&:strip) %> # Optional network isolation for the Colonel admin surfaces (/colonel shell # and /api/colonel API). When set to a non-empty list of CIDR ranges, any # request whose trusted-proxy-resolved client IP falls OUTSIDE the allowlist # receives a 404 (indistinguishable-from-absent, not a 403) on both # surfaces. This is defense-in-depth ON TOP OF the two app-layer auth layers # (role=colonel at the router + verify_one_of_roles!(colonel:true) in each # logic class), which still enforce beneath it. # # Empty/unset (default): NO-OP. Both surfaces stay reachable and the auth # layers are the sole gate — the correct posture for self-hosted # single-container installs, which cannot require a VPN. A list that IS set # but where no entry parses as a CIDR is the opposite case and denies both # surfaces (with an ERROR at boot): a list an operator wrote is never # silently disabled. Set this on cloud # deployments to private ranges only (e.g. a Tailscale/VPN CGNAT range like # 100.64.0.0/10, or an office RFC1918 range). Self-hosted operators who want # isolation without app config can instead front the surfaces with a reverse # proxy (see docs/operations/admin-network-isolation.md). # # IMPORTANT: resolution uses the trusted-proxy-aware client IP, so behind a # reverse proxy you must also configure site.network.trusted_proxy or every # request resolves to the proxy hop. A raw X-Forwarded-For header cannot # bypass the allowlist. # # Entries may be as fine as a single host (/32, IPv6 /128): membership is # judged against the full-precision resolved client IP, even though request # logs carry only its privacy-masked form. An entry that does not parse as # a CIDR is dropped and named in a boot WARN. # # Example: ["100.64.0.0/10", "10.0.0.0/8"] allowed_cidrs: <%= ENV['ADMIN_ALLOWED_CIDRS']&.split(',')&.map(&:strip) || [] %> # Brand pack selection (#3739, v2 #3774). A brand pack is the single unit of # branding: a directory of root-served assets (favicon.ico, icon-*.png, # site.webmanifest, etc.) plus an optional brand.yaml identity manifest # (colours, product name — absorbed into the brand: block below at boot, as a # fallback beneath operator config and BRAND_* env). # # Resolution ALWAYS lands on a pack: an unset brand_pack resolves to the # tracked `default` pack (public/branding/default, the neutral keyhole set). # A brand_pack NAME is resolved across two search roots, first existing wins: # 1. etc/branding/ — operator space (runtime mounts / confext) # 2. public/branding/ — vendor space (tracked default + generated packs) # brand_assets_dir (an explicit path, e.g. a runtime mount) wins over # brand_pack; a missing path or unknown name falls back to the default pack. brand_pack: <%= ENV['BRAND_PACK']&.to_json %> brand_assets_dir: <%= ENV['BRAND_ASSETS_DIR']&.to_json %> # Brand block. Each BRAND_* env var is interpolated as a JSON-quoted scalar # (`&.to_json`) so the value survives the YAML layer intact. Without quoting, an # operator value with YAML-significant characters — a leading '#' (hex color), # a leading '&'/'*'/'!', an embedded ': ' or a newline — is silently dropped to # nil or, worse, aborts the whole document at parse time (boot failure) before # Ruby ever sees it. An unset var interpolates to nothing, which YAML reads as # nil. Onetime::Config#normalize_brand (run in after_load) stays the authority: # it re-reads the env vars, trims blanks to nil, and coerces button_text_light # to a real boolean; an env-set field always wins over a YAML-supplied one. # # Precedence for these identity scalars (lowest to highest, #3774): # built-in defaults < brand pack brand.yaml < this brand: config < BRAND_* env # The pack manifest layer (Config#apply_brand_manifest) fills only keys this # block left nil, so an operator value here always wins over a pack, and env # always wins over both. The tracked default pack ships a value-free brand.yaml, # so an unconfigured install keeps every key nil (neutral posture, #3049). brand: primary_color: <%= ENV['BRAND_PRIMARY_COLOR']&.to_json %> product_name: <%= ENV['BRAND_PRODUCT_NAME']&.to_json %> product_domain: <%= ENV['BRAND_PRODUCT_DOMAIN']&.to_json %> support_email: <%= ENV['BRAND_SUPPORT_EMAIL']&.to_json %> corner_style: <%= ENV['BRAND_CORNER_STYLE']&.to_json %> font_family: <%= ENV['BRAND_FONT_FAMILY']&.to_json %> button_text_light: <%= ENV['BRAND_BUTTON_TEXT_LIGHT']&.to_json %> # Masthead/email logo. An absolute http(s) URL works everywhere; a brand pack # can instead carry its own brand-logo.svg / brand-logo.png and set this to the # served path (e.g. "/brand-logo.svg", #3774). A root-relative value renders in # the web UI but is omitted from emails (which need an absolute URL). logo_url: <%= ENV['BRAND_LOGO_URL']&.to_json %> # Dark-theme variant of the masthead logo, shown when the app's dark mode is # active. Web UI only (emails always use logo_url). A pack can carry it as # brand-logo-dark.svg / brand-logo-dark.png and set this to the served path. logo_dark_url: <%= ENV['BRAND_LOGO_DARK_URL']&.to_json %> # Accessible name for the brand logo. Unset falls back to an i18n string # derived from the product name. logo_alt: <%= ENV['BRAND_LOGO_ALT']&.to_json %> favicon_url: <%= ENV['BRAND_FAVICON_URL']&.to_json %> # URL overrides for the mobile/social variety pack served in the HTML head. # When unset, neutral bundled defaults (public/web) are used; replacing the # files via the brand directory is the alternative to these URL overrides. apple_touch_icon_url: <%= ENV['BRAND_APPLE_TOUCH_ICON_URL']&.to_json %> og_image_url: <%= ENV['BRAND_OG_IMAGE_URL']&.to_json %> totp_issuer: <%= ENV['BRAND_TOTP_ISSUER']&.to_json %> signature_name: <%= ENV['BRAND_SIGNATURE_NAME']&.to_json %> features: regions: enabled: <%= ENV['REGIONS_ENABLED'] == 'true' || false %> current_jurisdiction: <%= ENV['JURISDICTION'] || nil %> # Available jurisdictions for regional deployment # # Environment Variable Format: # JURISDICTIONS=ID:domain,ID:domain,... # where ID is the jurisdiction identifier (e.g., EU, CA, US) # and domain is the hostname for that jurisdiction # # Examples: # JURISDICTIONS=EU:eu.example.com,CA:ca.example.com # JURISDICTIONS=US:us.onetimesecret.com,EU:eu.onetimesecret.com,APAC:apac.onetimesecret.com # jurisdictions: <%= ENV['JURISDICTIONS'] || '' %> # Incoming Secrets Feature # Allows anonymous users to send encrypted secrets to pre-configured recipients # via a dedicated web form at /incoming. Recipients receive email notifications. incoming: # Enable or disable the incoming secrets feature. # # This flag governs the CANONICAL domain only. Per-domain incoming on # custom domains is gated by the org's incoming_secrets entitlement plus a # ready IncomingConfig, NOT by this flag (see the canonical/custom split in # RecipientResolver / CustomDomain::HomepageConfig#incoming_available?). To # emergency-disable incoming on custom domains, revoke the incoming_secrets # entitlement or set ORGS_INCOMING_SECRETS_ENABLED=false. enabled: <%= ENV['INCOMING_ENABLED'] == 'true' || false %> # Maximum character length for the memo/subject field memo_max_length: <%= ENV['INCOMING_MEMO_MAX_LENGTH'] || 50 %> # Default TTL for incoming secrets in seconds (7 days = 604800) default_ttl: <%= ENV['INCOMING_DEFAULT_TTL'] || 604800 %> # Optional default passphrase for all incoming secrets (nil = no passphrase) default_passphrase: <%= ENV['INCOMING_DEFAULT_PASSPHRASE'] || nil %> # Rate limiting for anonymous incoming-secret submissions (AZ9). Throttles # per client IP and per client-supplied recipient hash BEFORE any secret is # created or email is sent. Enabled by default (protective); set # INCOMING_RATE_LIMIT_ENABLED=false to opt out. See # lib/onetime/security/incoming_rate_limiter.rb. rate_limit: enabled: <%= ENV['INCOMING_RATE_LIMIT_ENABLED'] != 'false' %> # Submissions permitted per window from a single client IP. max_per_ip: <%= ENV['INCOMING_RATE_LIMIT_MAX_PER_IP'] || 10 %> # Higher backstop cap per recipient hash (catches IP-rotating spam). max_per_recipient: <%= ENV['INCOMING_RATE_LIMIT_MAX_PER_RECIPIENT'] || 30 %> # Counting window in seconds (1 hour). window: <%= ENV['INCOMING_RATE_LIMIT_WINDOW'] || 3600 %> # Lockout duration in seconds once a tier's cap is hit (1 hour). lockout: <%= ENV['INCOMING_RATE_LIMIT_LOCKOUT'] || 3600 %> # Recipients who can receive incoming secrets # Each recipient needs an email and optional display name. # Email addresses are hashed at startup and never exposed in API responses. # # Environment Variable Format: # INCOMING_RECIPIENT_N=email[,name] # where N is 1, 2, 3, etc. and name is optional # # Examples: # INCOMING_RECIPIENT_1=support@example.com,Support Team # INCOMING_RECIPIENT_2=security@example.com,Security Team # INCOMING_RECIPIENT_3=admin@example.com (name defaults to 'admin') # recipients: - <%= ENV['INCOMING_RECIPIENT_1']&.split(',') %> - <%= ENV['INCOMING_RECIPIENT_2']&.split(',') %> - <%= ENV['INCOMING_RECIPIENT_3']&.split(',') %> - <%= ENV['INCOMING_RECIPIENT_4']&.split(',') %> domains: enabled: <%= ENV['DOMAINS_ENABLED'] == 'true' || false %> # When true, secret creation rejects an unverified custom share_domain # with a form error. When false (default), creation is allowed regardless # of the domain's verification status. Canonical domains are unaffected. require_verified: <%= ENV['DOMAINS_REQUIRE_VERIFIED'] == 'true' || false %> # The default domain used for link URLs. When not set or empty, # site.host is used. default: <%= ENV['DEFAULT_DOMAIN'] || nil %> # LINK_DOMAINS — comma-separated hosts offered in the link-domain # picker. For installs whose canonical host is an internal platform # address (e.g. ge-abcd123.eu.otshosted.com) that must keep serving # the app but must never be offered to customers as a link domain. # # Three-way semantics — all deliberate: # unset -> the picker offers the canonical domain # (every pre-existing install is unchanged) # set -> the picker offers exactly these hosts; the # canonical domain appears only if listed # set-but-empty -> boot error naming LINK_DOMAINS # (Onetime::Config.validate_link_domains!) # # A set list that names only UNPARSEABLE hosts is the same boot # error: there is no safe fallback for it. Offering the canonical # domain instead would contradict the request (hiding that host is # the whole point), and offering nothing leaves the picker empty, so # the typo has to fail loud. A list that mixes good and bad entries # boots — the bad one is dropped and logged. # # This is NOT a UI-only setting: every entry joins the canonical # host set, so requests to these hosts are classified :canonical and # serve the app instead of being rejected as :invalid. # # The `&.` chain is load-bearing — it is what keeps unset (nil) and # set-but-empty ([]) distinguishable through ERB+YAML. Do NOT copy # the `|| nil` idiom used by `default:` above; it collapses the two. # `link_domains` is also deliberately absent from Config::DEFAULTS — # deep_merge restores the default for any nil in loaded config, # which would make set-but-empty undetectable. link_domains: <%= ENV['LINK_DOMAINS']&.split(',')&.map(&:strip) %> # Domain validation and certificate management strategy # Options: # - passthrough: No validation or cert management # - approximated: Use approximated.app API for SSL certs and validation (requires cluster config) # - caddy_on_demand: Use Caddy's on-demand TLS (requires internal ACME endpoint) validation_strategy: <%= ENV['DOMAINS_VALIDATION_STRATEGY'] || 'passthrough' %> # Approximated proxy configuration (required for 'approximated' strategy) approximated: api_key: <%= ENV['APPROXIMATED_API_KEY'] || nil %> proxy_ip: <%= ENV['APPROXIMATED_PROXY_IP'] || nil %> proxy_host: <%= ENV['APPROXIMATED_PROXY_HOST'] || nil %> proxy_name: <%= ENV['APPROXIMATED_PROXY_NAME'] || nil %> vhost_target: <%= ENV['APPROXIMATED_VHOST_TARGET'] || nil %> # Internal ACME endpoint configuration (for 'caddy_on_demand' strategy) # This endpoint allows Caddy to verify domains before issuing certificates. # # When enabled is true, the ACME app is auto-mounted inside the main # application process and available on the loopback interface at the # main app's port (e.g. http://127.0.0.1:3000/api/internal/acme/ask). # # When enabled is false, the ACME app must be run as a standalone # process: rackup apps/internal/acme/config.ru # It will bind to the listen_address and port configured below. # # The listen_address and port settings only apply to standalone mode. acme: enabled: <%= ENV['ACME_ENDPOINT_ENABLED'] == 'true' %> # IP address to bind to in standalone mode (should always be localhost) listen_address: <%= ENV['ACME_LISTEN_ADDRESS'] || '127.0.0.1' %> # Port for standalone mode (Caddy's ask directive points here) port: <%= ENV['ACME_PORT'] || '12020' %> # Organizations UI # Shows the organization switcher in the navigation bar. Organizations # always exist under the hood (every customer has one for Stripe billing). # This flag controls whether users can see and switch between multiple orgs. organizations: enabled: <%= ENV['ENABLE_ORGS'] == 'true' %> sso_enabled: <%= ENV['ORGS_SSO_ENABLED'] == 'true' %> custom_mail_enabled: <%= ENV['ORGS_CUSTOM_MAIL_ENABLED'] == 'true' %> incoming_secrets_enabled: <%= ENV['ORGS_INCOMING_SECRETS_ENABLED'] == 'true' %> # Gates exposure (UI + list API) of the organization secret-activity # trail; pairs with the `audit_logs` entitlement. Defaults ON, unlike # the sibling flags above, because the trail ships working out of the # box — this is an opt-out exclusion (ORGS_AUDIT_LOGS_ENABLED=false), # not an opt-in feature. Does not stop event collection. audit_logs_enabled: <%= ENV['ORGS_AUDIT_LOGS_ENABLED'] != 'false' %> # Secret Activity Trail (collection) # The data-existence axis of the org secret-activity trail: whether events # are recorded at all (GDPR data minimization). UI + list API exposure is # the separate organizations.audit_logs_enabled axis above. Both default # ON; these are opt-out exclusions. secret_activity: # When false, event recording pauses; existing events stay readable. collect: <%= ENV['SECRET_ACTIVITY_COLLECT'] != 'false' %> # Newest events retained per organization (floor: 100). Lowering this # deletes (not hides) each org's oldest events on its next write. max_events: <%= ENV['SECRET_ACTIVITY_MAX_EVENTS'] || 10000 %> # Country-level geo capture on Secret Activity events (#3989). A distinct # axis from `collect`: gates ONLY the net_country attribute, pending # counsel review of org-tier geo exposure (ADR-021 Decision 4 open # question; ADR-022 does not yet cover this attribute). Defaults OFF # (opt-in) — the inverse polarity of the sibling flags — because this is # the not-yet-reviewed surface, not an already-approved one. geo_country_enabled: <%= ENV['SECRET_ACTIVITY_GEO_COUNTRY_ENABLED'] == 'true' %> # Main Database Configuration redis: # Redis/Valkey connection URI - Compatible with Redis, Valkey, and other # Redis-compatible servers. # # Environment Variable Precedence (highest to lowest): # 1. VALKEY_URL or REDIS_URL - Explicit connection string # 2. IN_DOCKER=1 - Force Docker configuration (redis://host.docker.internal:6379) # 3. AUTO_DETECT_DOCKER=1 - Enable automatic Docker detection via /.dockerenv file # 4. Default - Local development (redis://CHANGEME@127.0.0.1:6379) # # Format: redis://[:password@]host[:port]/[db-number] # Examples: # - redis://mypassword@localhost:6379 # Simple password auth # - redis://user:pass@localhost:6379 # Username/password auth # - redis://host.docker.internal:6379 # Docker environment # - redis://127.0.0.1:6379 # Local development uri: >- <%= ENV['VALKEY_URL'] || ENV['REDIS_URL'] || (ENV['IN_DOCKER'] == '1' || (ENV['AUTO_DETECT_DOCKER'] == '1' && File.exist?('/.dockerenv')) ? 'redis://host.docker.internal:6379' : 'redis://CHANGEME@127.0.0.1:6379') %> # Database Mapping Configuration # By default, all data types use database 0 for simplified connection pooling # and compatibility with Redis-as-a-Service providers. For advanced setups, # you can separate data across different Redis logical databases (0-15) # by setting environment variables or modifying the values below. dbs: custom_domain: <%= ENV['VALKEY_DBS_CUSTOM_DOMAIN'] || ENV['REDIS_DBS_CUSTOM_DOMAIN'] || 0 %> customer: <%= ENV['VALKEY_DBS_CUSTOMER'] || ENV['REDIS_DBS_CUSTOMER'] || 0 %> metadata: <%= ENV['VALKEY_DBS_METADATA'] || ENV['REDIS_DBS_METADATA'] || 0 %> secret: <%= ENV['VALKEY_DBS_SECRET'] || ENV['REDIS_DBS_SECRET'] || 0 %> feedback: <%= ENV['VALKEY_DBS_FEEDBACK'] || ENV['REDIS_DBS_FEEDBACK'] || 0 %> # Sending Emails emailer: # Local Development with Mailpit # ----------------------------- # Mailpit is a dev SMTP server that captures emails for testing # Install: brew install mailpit # Start: mailpit # Web UI: http://localhost:8025 # # :mode: smtp # Use SMTP mode for local testing # :from: secure@onetimesecret.com # Sender address # :from_name: OTS Support # Sender name # :host: 127.0.0.1 # Mailpit host # :port: 1025 # Mailpit default SMTP port # :user: ~ # No auth needed for Mailpit # :pass: ~ # No auth needed for Mailpit # :auth: false # Disable SMTP auth for Mailpit # :tls: false # Disable TLS for local testing # Production Settings (for reference) # ---------------------------------- mode: <%= ENV['EMAILER_MODE'] || 'smtp' %> # Provider for custom mail sender domain provisioning (DNS/DKIM). # When set, decouples domain provisioning from the sending transport. # Valid values: ses, sendgrid, lettermint, smtp2go, smtp # If unset, falls back to mode (EMAILER_MODE). sender_provider: <%= ENV['CUSTOM_MAIL_PROVIDER'] || nil %> region: <%= ENV['EMAILER_REGION'] || 'smtp' %> from: <%= ENV['FROM_EMAIL'] || ENV['FROM'] || 'CHANGEME@example.com' %> from_name: <%= ENV['FROM_NAME'] || 'Support' %> reply_to: <%= ENV['REPLYTO_EMAIL'] || ENV['FROM_EMAIL'] || nil %> host: <%= ENV['SMTP_HOST'] || 'smtp.provider.com' %> port: <%= ENV['SMTP_PORT'] || 587 %> user: <%= ENV['SMTP_USERNAME'] %> # The double quotes are important b/c credentials can have characters that break YAML parsing. pass: "<%= ENV['SMTP_PASSWORD'] %>" auth: <%= ENV['SMTP_AUTH'] || nil %> tls: <%= ENV['SMTP_TLS'] %> # Recipient ("To:") address for /feedback submissions. # When set, feedback emails are delivered here instead of the first colonel # in the database. Useful for routing to a shared inbox or alias. feedback_to: <%= ENV['FEEDBACK_TO_EMAIL'] || nil %> # Whether to include the logo image in HTML emails (default: false). # Set to true only when recipients can reach the app's base URI to # load the image; otherwise a broken image looks less trustworthy. show_logo: <%= ENV['EMAILER_SHOW_LOGO'] == 'true' %> # Email Provider DNS Validation Settings # -------------------------------------- # Configuration for sender domain validation strategies. These settings # control how DNS records are generated for DKIM, SPF, and related email # authentication when setting up custom mail sender domains. # # Each provider has sensible defaults; override only when needed. # All settings are optional - the system works without this section. # email_providers: # AWS SES configuration # See docs/architecture/custom-mail-sender-ses.md for the domain-level flow. ses: # AWS region for SES sender-domain provisioning (the SESv2 API client used # to create/get/delete email identities). This is independent of # EMAILER_REGION, which configures the install-level transactional mailer. # For data-residency, use a regional endpoint such as ca-central-1 (Canada) # or ap-southeast-2 (Sydney). Confirm SES is available in the region. # Override via CUSTOM_MAIL_SES_REGION env var region: <%= ENV['CUSTOM_MAIL_SES_REGION'] || 'us-east-1' %> # AWS credentials for the SES provisioning API client, kept independent of # the SMTP/transactional mailer credentials. build_provider_config('ses') # otherwise resolves the key pair from emailer.user/pass (i.e. # SMTP_USERNAME/SMTP_PASSWORD when EMAILER_MODE=smtp), which would silently # use the SMTP login as the AWS key. These dedicated knobs fall back to the # standard AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY and, when set, override # the emailer-derived values. Leave unset only when SES is also the delivery # backend (EMAILER_MODE=ses) and the emailer already carries the AWS keys. # Override via CUSTOM_MAIL_SES_ACCESS_KEY_ID / CUSTOM_MAIL_SES_SECRET_ACCESS_KEY access_key_id: <%= ENV['CUSTOM_MAIL_SES_ACCESS_KEY_ID'] || ENV['AWS_ACCESS_KEY_ID'] %> secret_access_key: <%= ENV['CUSTOM_MAIL_SES_SECRET_ACCESS_KEY'] || ENV['AWS_SECRET_ACCESS_KEY'] %> # Number of DKIM selectors (SES uses 3 by default) dkim_selector_count: 3 # SPF include domain spf_include: amazonses.com # SendGrid configuration sendgrid: # Branding subdomain prefix (appears in CNAME records) # Override via CUSTOM_MAIL_SENDGRID_SUBDOMAIN env var subdomain: <%= ENV['CUSTOM_MAIL_SENDGRID_SUBDOMAIN'] || 'em' %> # DKIM selector names dkim_selectors: - s1 - s2 # SPF include domain spf_include: sendgrid.net # Lettermint configuration # # Lettermint has TWO separate APIs with different auth: # 1. Sending API - uses x-lettermint-token header (project token) # 2. Team API - uses Authorization: Bearer header (team token) # # Domain provisioning requires the Team API token. # lettermint: # Project token for Lettermint Sending API (email delivery) # Required when emailer.mode is 'lettermint' # Override via LETTERMINT_API_TOKEN env var api_token: <%= ENV['LETTERMINT_API_TOKEN'] %> # Team token for Lettermint Team API (domain provisioning) # Required for custom mail sender domain management # Override via LETTERMINT_TEAM_TOKEN env var team_token: <%= ENV['LETTERMINT_TEAM_TOKEN'] %> # API base URL (for enterprise/on-premise deployments) # Override via LETTERMINT_BASE_URL env var api_base_url: <%= ENV['LETTERMINT_BASE_URL'] || 'https://api.lettermint.co/v1' %> # DKIM selector names returned by Lettermint domain provisioning dkim_selectors: - lm1 - lm2 # SPF CNAME subdomain prefix (e.g., 'lm-bounces' creates lm-bounces.yourdomain.com) # Override via CUSTOM_MAIL_LETTERMINT_SPF_CNAME_PREFIX env var spf_cname_prefix: <%= ENV['CUSTOM_MAIL_LETTERMINT_SPF_CNAME_PREFIX'] || 'lm-bounces' %> # SPF CNAME target domain (Lettermint maintains SPF at this target) # Override via CUSTOM_MAIL_LETTERMINT_SPF_CNAME_TARGET env var spf_cname_target: <%= ENV['CUSTOM_MAIL_LETTERMINT_SPF_CNAME_TARGET'] || 'bounces.lmta.net' %> # SMTP2GO configuration # # SMTP2GO is an NZ-based provider (Christchurch) used for our NZ region. # A single API key covers API-based sending and sender-domain # verification; domains are provisioned via POST /domain/add, which # returns the DKIM, return-path, and tracking CNAME records to publish. # smtp2go: # API key for the SMTP2GO API (email delivery and domain provisioning) # Required when emailer.mode is 'smtp2go' # Override via SMTP2GO_API_KEY env var api_key: <%= ENV['SMTP2GO_API_KEY'] %> # API base URL # Override via SMTP2GO_BASE_URL env var api_base_url: <%= ENV['SMTP2GO_BASE_URL'] || 'https://api.smtp2go.com/v3' %> # Return-path subdomain prefix (e.g., 'bounce' creates bounce.yourdomain.com) # Published by the customer as a CNAME record so the envelope sender # aligns with their domain for SPF. Provisioned via POST /domain/add. # Override via CUSTOM_MAIL_SMTP2GO_RETURNPATH_SUBDOMAIN env var returnpath_subdomain: <%= ENV['CUSTOM_MAIL_SMTP2GO_RETURNPATH_SUBDOMAIN'] || 'bounce' %> # Tracking subdomain prefix (e.g., 'track' creates track.yourdomain.com) # Published by the customer as a CNAME record for open/click tracking. # Override via CUSTOM_MAIL_SMTP2GO_TRACKING_SUBDOMAIN env var tracking_subdomain: <%= ENV['CUSTOM_MAIL_SMTP2GO_TRACKING_SUBDOMAIN'] || 'track' %> # Fast-accept mode. Default false = synchronous delivery accounting: the # response carries succeeded/failed/failures, so a failed recipient is # reported instead of silently dropped. Set true only to opt into # fire-and-forget (lower latency, no per-recipient failure detection). # Override via CUSTOM_MAIL_SMTP2GO_FASTACCEPT env var (true/false) fastaccept: <%= ENV.fetch('CUSTOM_MAIL_SMTP2GO_FASTACCEPT', 'false') == 'true' %> mail: truemail: # Available validation types: :regex, :mx, :mx_blacklist, :smtp default_validation_type: :regex # Required for :smtp validation verifier_email: <%= ENV['VERIFIER_EMAIL'] || 'CHANGEME@example.com' %> #:verifier_domain: <%= ENV['VERIFIER_DOMAIN'] || 'example.com' %> #:connection_timeout: 2 #:response_timeout: 2 #:connection_attempts: 3 #:validation_type_for: # 'example.com': :regex # # Truemail will only validate email addresses that match the # domains listed in :allowed_domains. If the domain is not # listed, the email address will always be considered invalid. allowed_domains_only: false # # Email addresses in this list will always be valid. #:allowed_emails: [] # # Email addresses in this list will always be invalid. #:blocked_emails: [] # # Addresses with these domains will always be valid #:allowed_domains: [] # # Addresses with these domains will always be invalid #:blocked_domains: [] # # Exclude these IP addresses from the MX lookup process. #:blocked_mx_ip_addresses: [] # # Name servers to use for MX et al record lookup. # Default is CloudFlare, Google, Oracle/OpenDNS servers. dns: - 1.1.1.1 - 8.8.4.4 - 208.67.220.220 #:smtp_port: 25 # # End smtp validation after the first invalid response rather than # retrying, followed by trying the next server. Can reduce the time # time to validate an email address, but may not catch all issues. smtp_fail_fast: false # # Parse the content of the SMTP error message to determine if the # email address is valid. This can be useful for some SMTP servers # that don't return exact answers. smtp_safe_check: true # # Whether to disable the RFC MX lookup flow. When true, only DNS # validation will be performed on MX and Null MX records. not_rfc_mx_lookup_flow: false # # Override default regular expression pattern for email addresses # and/or the content in SMTP error messages. #:email_pattern: /regex_pattern/ #:smtp_error_body_pattern: /regex_pattern/ # # Log to the console, a file, or both. The ruby process must have # write access to the log file. The log file will be created if it # does not exist. Log file rotation is not handled by the app. logger: # One of: :error (default), :unrecognized_error, :recognized_error, :all. tracking_event: 'error' stdout: true # log_absolute_path: '/home/app/log/truemail.log' # Background Job Processing (RabbitMQ) # ------------------------------------ # Async processing for emails, notifications, webhooks, and scheduled tasks. # When disabled, email delivery falls back to synchronous mode. jobs: # Global toggle for background job system enabled: <%= ENV['JOBS_ENABLED'] == 'true' || false %> # RabbitMQ connection URL # Format: amqp://[user:password@]host[:port][/vhost] # # The vhost dev doesn't exist in RabbitMQ. Either create it: # # rabbitmqctl add_vhost dev # rabbitmqctl set_permissions -p dev guest ".*" ".*" ".*" # rabbitmq_url: <%= ENV['RABBITMQ_URL'] || 'amqp://guest:guest@localhost:5672/dev' %> # Publisher settings (Puma process) # Channel pool size for multi-threaded web server channel_pool_size: <%= ENV['RABBITMQ_CHANNEL_POOL_SIZE']&.to_i || 5 %> # Fallback to synchronous delivery when RabbitMQ unavailable # Recommended: true for production to ensure emails always send fallback_to_sync: <%= ENV['JOBS_FALLBACK_SYNC'] != 'false' %> # Worker settings (consumer processes) workers: email: threads: <%= ENV['EMAIL_WORKER_THREADS']&.to_i || 4 %> prefetch: <%= ENV['EMAIL_WORKER_PREFETCH']&.to_i || 10 %> notifications: threads: <%= ENV['NOTIFICATION_WORKER_THREADS']&.to_i || 2 %> prefetch: <%= ENV['NOTIFICATION_WORKER_PREFETCH']&.to_i || 5 %> billing: threads: <%= ENV['BILLING_WORKER_THREADS']&.to_i || 2 %> prefetch: <%= ENV['BILLING_WORKER_PREFETCH']&.to_i || 5 %> # Scheduler settings (rufus-scheduler daemon) scheduler: enabled: <%= ENV['JOBS_SCHEDULER_ENABLED'] == 'true' || false %> # Plan Cache Refresh Job # Proactively refreshes Billing::Plan cache from Stripe API every 6 hours. # This ensures plan data remains available even if Stripe webhooks fail. # The cache has a 12-hour TTL, so 6-hour refresh provides redundancy. # Skips gracefully if no Stripe API key is configured (standalone mode). plan_cache_refresh: enabled: true # Catalog Retry Job # Retries webhook events that were blocked by the Stripe circuit breaker. # When Stripe API is unavailable, catalog update webhooks (product.updated, # price.created, etc.) are queued for retry rather than failing. This job # processes those queued events once the circuit breaker closes. # Runs every 2 minutes when enabled; skips if circuit is still open. catalog_retry: enabled: true # DLQ Email Consumer Job # Processes failed email messages from the dead-letter queue. # Runs on a schedule to inspect DLQ messages and re-publish recoverable # failures or log permanent failures for investigation. dlq_consumer: enabled: true # Domain refresh job # Periodically refreshes cached vhost/resolving status for custom domains # so the domains-list page shows current state without depending on a user # visiting the verify page. See issue #3080. # Skips gracefully when no custom domains are registered. domain_refresh: enabled: true # How often to refresh (rufus-scheduler interval) check_interval: '30m' # Maximum domains processed per run (rate limiting) batch_size: 200 # Seconds between Approximated API calls within a run rate_limit: 0.5 # Expiration warning emails # Send email notifications before secrets expire to warn recipients expiration_warnings: # Enable/disable expiration warning emails enabled: false # How often to scan for expiring secrets (rufus-scheduler interval) check_interval: '1h' # Send warning N hours before secret expires warning_hours: 24 # Only warn for secrets with TTL greater than this (hours) # Prevents spamming for short-lived secrets min_ttl_hours: 48 # Maximum warnings to process per job run (rate limiting) # Prevents queue overflow; remaining secrets processed in next run batch_size: 100 # Auto-fetch custom-domain favicon from the live domain # Discovers and downloads a favicon for verified custom domains under an # SSRF guard, then populates custom_domain.icon. See issue #3780. # HTTPS-only; user-uploaded icons are never overwritten. favicon_fetch: # Feature flag — default ON (see #3780) enabled: true # Seconds per HTTP fetch (connect + read) timeout: 5 # Response size ceiling in bytes (100 KB), streamed-enforced max_response_bytes: 102400 # Maximum redirects followed, each re-validated against the SSRF guard max_redirects: 3 # Allowed image content types. SVG is intentionally excluded (script/XXE # risk) and rejected at fetch time. This array REPLACES on override # (deep_merge) — restate it in full to change the set. allowed_content_types: - image/x-icon - image/vnd.microsoft.icon - image/png # Nightly favicon backfill scan (#3780) # Paginates the full custom-domain set and enqueues a favicon fetch for each # domain still missing an auto-fetched icon whose backoff window has elapsed. # Requires BOTH this flag AND favicon_fetch.enabled (the worker must consume). favicon_backfill: # Feature flag — default OFF enabled: false # Scan schedule (cron); nightly at 03:00 by default cron: '0 3 * * *' # Domains loaded+scanned per page while paginating the full set batch_size: 500 # Stop retrying a domain after this many terminal non-success attempts max_attempts: 6 # Backoff base in days (doubles each attempt: 1, 2, 4, 8, ...) base_days: 1 # Backoff ceiling in days (the doubling never schedules further out than this) cap_days: 30 # Data maintenance jobs # Scheduled jobs for Redis data consistency checks and repairs. # All jobs ship with auto_repair: false — enable only after # reviewing audit reports over multiple cycles. maintenance: # Master toggle — disables all maintenance jobs when false enabled: false # Phase 1: Remove phantom members from sorted sets (hourly) phantom_cleanup: enabled: false interval: '1h' batch_size: 500 auto_repair: false # Phase 2: Read-only data consistency audit (every 6h) data_audit: enabled: false interval: '6h' sample_size: 100 # Phase 3: GC stale participation sorted set members (daily 5 AM) participation_gc: enabled: false cron: '0 5 * * *' batch_size: 500 auto_repair: false # Phase 4: Reconcile unique indexes (daily 4 AM) index_rebuild: enabled: false cron: '0 4 * * *' auto_repair: false # Phase 5: Rebuild instances sorted sets (weekly Sunday 3 AM) instances_rebuild: enabled: false cron: '0 3 * * 0' auto_repair: false # Familia model housekeeping chores (nightly 2 AM) # Iterates models that declare `feature :housekeeping` and runs every # registered chore. Disable when no chores are active. housekeeping: enabled: false cron: '0 2 * * *' internationalization: enabled: <%= ENV['I18N_ENABLED'] == 'true' || false %> default_locale: <%= ENV['I18N_DEFAULT_LOCALE'] || 'en' %> # Date/time display format. Accepts a preset keyword or a date-fns pattern. # Setting date_format alone controls both date-only and date+time display. # Set datetime_format only if you need a different format for date+time contexts. # # Presets: # locale - browser-native formatting (default) # iso8601 - 2026-03-21 / 2026-03-21 14:30:00 # us - 03/21/2026 / 03/21/2026 2:30:00 PM # eu - 21/03/2026 / 21/03/2026 14:30 # eu-dot - 21.03.2026 / 21.03.2026 14:30 # uk - 21 Mar 2026 / 21 Mar 2026 14:30 # long - March 21, 2026 / March 21, 2026 2:30 PM # # Or pass a raw date-fns pattern: https://date-fns.org/docs/format date_format: <%= ENV['I18N_DATE_FORMAT'] || 'locale' %> datetime_format: <%= ENV['I18N_DATETIME_FORMAT'] || 'locale' %> fallback_locale: ca: [ca_ES, en] ca-ES: [ca_ES, en] da: [da_DK, en] da-DK: [da_DK, en] de: [de, de_AT, en] de-AT: [de_AT, de, en] el: [el_GR, en] el-GR: [el_GR, en] fr: [fr_FR, fr_CA, en] fr-CA: [fr_CA, fr_FR, en] it: [it_IT, en] mi: [mi_NZ, en] mi-NZ: [mi_NZ, en] pt: [pt_BR, pt_PT, en] pt-BR: [pt_BR, pt_PT, en] pt-PT: [pt_PT, pt_BR, en] sl: [sl_SI, en] sl-SI: [sl_SI, en] sv: [sv_SE, en] sv-SE: [sv_SE, en] default: [en] # A list of ISO language codes (e.g., 'en' for English, 'es' # for Spanish, etc.). There is a corresponding file in # generated/locales with the same name containing the translated # text. If it's not selected automatically, users are able to # select their preferred language by using the toggle in the # footer or in the settings modal if they're logged in. locales: - ar - bg - ca_ES - cs - da_DK - de - de_AT - el_GR - en - eo - es - fr_CA - fr_FR - he - hu - it_IT - ja - ko - mi_NZ - nl - pl - pt_BR - pt_PT - ru - sl_SI - sv_SE - tr - uk - vi - zh diagnostics: # If this is false, the rest of the settings are ignored enabled: <%= ENV['DIAGNOSTICS_ENABLED'] == 'true' || false %> sentry: # Default Sentry configuration that applies to both frontend and # backend. Values set here are overridden by codebase-specific ones. # # `dsn` - Primary Sentry DSN. # `sampleRate` - Percentage of events to sample (0.0 to 1.0) # `maxBreadcrumbs` - Maximum number of breadcrumbs to capture # `logErrors` - Whether to log errors to console # `org_id` - Sentry organization ID. When set, the Ruby SDK enables # strict trace continuation (sentry-ruby 6.5+): incoming distributed # traces are only continued if their sentry-org_id baggage matches # this value, which prevents a third-party service instrumented by # Sentry under a different org from polluting our traces. Self-hosted # Sentry DSNs cannot be parsed for org_id, so it must be set # explicitly here. Leave nil to keep strict mode off and continue all # inbound traces regardless of their org. The value propagates to # each peer (backend/frontend/workers) via apply_defaults_to_peers. defaults: dsn: <%= ENV['SENTRY_DSN'] || nil %> sampleRate: <%= ENV['SENTRY_SAMPLE_RATE'] || '0.10' %> maxBreadcrumbs: <%= ENV['SENTRY_MAX_BREADCRUMBS'] || 5 %> logErrors: <%= ENV['SENTRY_LOG_ERRORS'] != 'false' %> org_id: <%= ENV['SENTRY_ORG_ID'] || nil %> # Ruby backend-specific Sentry configuration # # `dsn` - Backend-specific Sentry DSN backend: dsn: <%= ENV['SENTRY_DSN_BACKEND'] || nil %> # Vue frontend-specific Sentry configuration # Options here map directly to @sentry/vue client options # These options are passed directly to @sentry/vue client initialization # and to maintain type safety, they must be typed in # src/types/diagnostics.ts DiagnosticsConfig interface. # # `dsn` - Frontend-specific Sentry DSN # `trackComponents` - Enable automatic instrumentation of Vue components frontend: dsn: <%= ENV['SENTRY_DSN_FRONTEND'] || nil %> trackComponents: <%= ENV['SENTRY_VUE_TRACK_COMPONENTS'] != 'false' %> # Background workers-specific Sentry configuration # Used for Sneakers workers and Rufus scheduler processes. # Falls back to backend DSN if not configured. # # `dsn` - Workers-specific Sentry DSN workers: dsn: <%= ENV['SENTRY_DSN_WORKERS'] || nil %> development: # Development Mode Configuration # # There are two ways to run the frontend in development: # # 1. Behind a reverse proxy (e.g., Caddy, nginx) # - Set :enabled to true # - Leave :frontend_host empty # - Configure your reverse proxy to handle /dist/* routes # # 2. Using the built-in rack-proxy (new) # - Set :enabled to true # - Set :frontend_host to 'http://localhost:5173' # - The application will proxy /dist/* requests to the Vite dev server # # When development mode is enabled, the application expects a Vite # development server to be running. This allows live-reloading of frontend # changes without rebuilding: # # $ pnpm run dev # VITE v5.3.4 ready in 38 ms # # -> Local: http://localhost:5173/dist/ # -> Network: use --host to expose # -> press h + enter to show help # # When disabled (or in production), the application serves pre-built assets # from public/web/dist instead. # enabled: <%= ['development', 'dev'].include?(ENV['RACK_ENV']) %> debug: <%= ['true', '1', 'yes'].include?(ENV['ONETIME_DEBUG']) %> # Frontend host configuration: # - Set to 'http://localhost:5173' to use built-in proxy # - Leave empty when using a reverse proxy (like nginx, caddy, etc) frontend_host: <%= ENV['FRONTEND_HOST'] || 'http://localhost:5173' %> # Domain Context Override for persona-based testing # # When enabled, allows simulating custom domain experiences without # actual DNS/domain setup. Supports three override mechanisms: # - DOMAIN_CONTEXT env var: Set at server startup for entire process # - O-Domain-Context header: Per-request override for API/curl testing # - Colonel UI component: Browser-based override via sessionStorage # # SECURITY: Must remain false in production. No query param support. domain_context_enabled: <%= ENV['DOMAIN_CONTEXT_ENABLED'] == 'true' %> # DEVELOPMENT TOOL: Allow application to run without a site.secret # # Only effective when development.enabled is true. The config normalization # layer forces this to false when development mode is off. # # WHY THIS EXISTS: # The application encrypts secret content using site.secret before storing # in Redis. If site.secret is nil/empty, encryption still occurs but with # a predictable key - making stored data vulnerable. This setting exists # to recover secrets that were accidentally created during such a state. # # DEFAULT: false (application fails to start if site.secret is nil) # # WARNING: Setting to true presents significant security risks: # - Secrets may be stored without proper encryption # - Unauthorized access to sensitive data becomes possible # - Secret link integrity cannot be guaranteed # # VALID USE CASES (temporary only): # 1. RECOVERY: You accidentally ran with nil secret and need to recover # existing secrets created during that time. Enable temporarily until # all affected secrets expire (max TTL period). # 2. MIGRATION: During a controlled migration between encryption schemes, # with proper security measures in place. # # BEHAVIOR WHEN TRUE: # - Application starts without failing # - Warning logs appear at startup # - When decrypting, CipherErrors with the real secret will cause # automatic retry with nil secret # allow_nil_global_secret: <%= ENV['ALLOW_NIL_GLOBAL_SECRET'] == 'true' || false %> # DEVELOPMENT TOOL: Auto-provisioning Basic Auth for testing # # Enables the devbasicauth strategy which auto-creates ephemeral test users. # Users require dev_ prefix on both username and apikey, and expire after 20h. # # SECURITY: Blocked in production (raises SecurityError on registration). # # USAGE: # curl -u dev_alice:dev_secretkey123 http://localhost:7143/api/v2/status # # The user dev_alice@dev.local is auto-created on first request. # # @see lib/onetime/application/auth_strategies/dev_basic_auth_strategy.rb # @see https://github.com/onetimesecret/onetimesecret/issues/2735 devbasicauth: <%= ENV['DEV_BASIC_AUTH'] == 'true' %> # DEVELOPMENT TOOL: Session Auth for browser-based dev workflows # # Enables the devsessionauth strategy which validates sessions belong to dev users. # Works with dev_*@dev.local users created by devbasicauth or dev login forms. # # SECURITY: Blocked in production (raises SecurityError on registration). # # USAGE: # 1. Create a dev user via devbasicauth or dev login endpoint # 2. Routes with auth=devsessionauth validate the session belongs to a dev user # # @see lib/onetime/application/auth_strategies/dev_session_auth_strategy.rb # @see https://github.com/onetimesecret/onetimesecret/issues/2735 devsessionauth: <%= ENV['DEV_SESSION_AUTH'] == 'true' %> # Compatibility Configuration # Controls how the boot process responds to deprecated configuration keys. compatibility: # When a removed/relocated config key or its environment variable is # detected at boot: # strict (default): raise an error and refuse to start # warn: log a migration message and continue # silent: ignore deprecated_config_mode: <%= ENV['DEPRECATED_CONFIG_MODE'] || 'strict' %> # Experimental features # # Opt-in, not-yet-stable capabilities. Each flag gates a feature that is safe to # disable at any time — rollback is a config flip. Flags graduate out of this # section once stable. # # Currently empty: the Colonel admin-console cutover flag was retired once the # rebuilt console became the sole admin frontend # (docs/specs/colonel-ui/50-cutover-hardening.md). Kept as an extension point # for future flags. experimental: {}