Configuration

Table of contents

  1. Introduction
  2. How the layers stack
  3. Reading a value
  4. Setting a value in your product
    1. Pointing at another database
  5. Setting a value from a feature
  6. Overriding a value per deployment
  7. Checking the resolved configuration
  8. Setting the secret key
  9. Signing with a key per purpose
  10. Production defaults
    1. Tuning database connections
    2. Allowing larger uploads
    3. Securing cookies
      1. Sharing the session with another site
    4. Adjusting the security headers
    5. Letting a feature extend the Content-Security-Policy
    6. Caching assets
  11. Reading the product’s version
  12. Advanced
  13. Reference
    1. Framework defaults
    2. Environment variables
  14. See also

Introduction

Your product reads its settings from app.config, like any Flask app. SPLENT fills it from four layers, and the stronger layer always wins.

Most products change a handful of keys and keep the rest. To see what yours runs with, and where each value came from, run product:config.

splent product:config
  KEY                      VALUE                 SOURCE
  ──────────────────────────────────────────────────────────────────────────────────
  NOTES_PAGE_SIZE          50                    product pyproject ([tool.splent.config])
  REDIS_URL                'redis://cache:6379'  feature (splent_feature_cache)
  SQLALCHEMY_DATABASE_URI  'mysql+pymysql://…'   framework default
  TIMEZONE                 'UTC'                 product (my_first_app)

How the layers stack

The four configuration layers, weakest to strongest WEAKEST TO STRONGEST 1 Framework default_config.py the defaults every product starts from 2 Features inject_config(app) the defaults each feature brings 3 Product config.py what your product chooses 4 Environment .env what this deployment says app.config what the running app reads splent product:config shows every value and the layer that set it
A stronger layer wins, whatever order the code runs in.

Features load after your product’s config.py, yet a feature’s default never replaces a value your product chose. Within one layer the last writer wins, and a feature always loads after the features it requires.


Reading a value

from flask import current_app

timezone = current_app.config["TIMEZONE"]

Templates read the same values through config, as in {{ config.TIMEZONE }}. For a key a feature declares in its schema, param() returns the typed value and raises LookupError for a misspelt key.

from splent_framework.params import param

page_size = param("notes", "NOTES_PAGE_SIZE")

Setting a value in your product

Set your features’ keys in the [tool.splent.config] table of your product’s pyproject.toml. Write only what you change.

[tool.splent.config]
NOTES_PAGE_SIZE = 50

Every other key, such as one of Flask’s, goes in src/<product>/config.py. The scaffold extends the framework’s classes and changes nothing, so add only what differs.

from splent_framework.configuration.default_config import (
    DevelopmentConfig as BaseDev,
    TestingConfig as BaseTest,
    ProductionConfig as BaseProd,
)


class DevelopmentConfig(BaseDev):
    pass


class TestingConfig(BaseTest):
    pass


class ProductionConfig(BaseProd):
    MAX_CONTENT_LENGTH = 64 * 1024 * 1024  # our features take large uploads

SPLENT_ENV picks the class, so dev loads DevelopmentConfig, test loads TestingConfig and prod loads ProductionConfig. A product without config.py runs on the framework’s classes.

Pointing at another database

The classes build SQLALCHEMY_DATABASE_URI from the MARIADB_* variables. To use another database, set it after super().__init__() and quote the credentials.

import os
from urllib.parse import quote


class TestingConfig(BaseTest):
    def __init__(self):
        super().__init__()
        user = quote(os.getenv("MARIADB_USER", "default_user"), safe="")
        password = quote(os.getenv("MARIADB_PASSWORD", "default_password"), safe="")
        self.SQLALCHEMY_DATABASE_URI = (
            f"mysql+pymysql://{user}:{password}@"
            f"{os.getenv('MARIADB_HOSTNAME', 'localhost')}:"
            f"{os.getenv('MARIADB_PORT') or 3306}/"
            f"{os.getenv('MARIADB_TEST_DATABASE', 'my_product_test_db')}"
        )

Unquoted, a password with @, /, #, : or ? breaks the URI. Use quote, not quote_plus, whose + SQLAlchemy reads back as a plus sign.


Setting a value from a feature

A feature sets its keys in inject_config(app), in its own config.py. Read each value from the environment, with a default that works in development.

import os


def inject_config(app):
    app.config.setdefault("NOTES_PAGE_SIZE", int(os.getenv("NOTES_PAGE_SIZE", "20")))
  • Never hardcode a secret or a host. Read it with os.getenv().
  • Read another feature’s value with app.config.get(). The features you require have loaded already.
  • update() replaces, setdefault() fills gaps. Either way, your product’s choice stays.
  • Leave SESSION_TYPE to the feature that keeps the sessions. Without one, the app stops at start and says so.

Declare the key in the feature’s [tool.splent.config_schema] and the framework checks its type at start.

feature:inject-config writes a feature’s config.py from the os.getenv() calls in its code.

splent feature:inject-config splent_feature_notes

Overriding a value per deployment

Set a variable named like the key in the deployment’s .env. For every key the framework or a feature reads from the environment, the variable wins over your product’s choice, with no new release.

MAX_CONTENT_LENGTH=64M
TIMEZONE=UTC

A blank variable counts as unset. When a variable overrules a value your product chose, product:config lists the key under Drift.

In development, product:env --merge copies every feature’s default into the product’s .env. That is why a feature’s key belongs in [tool.splent.config], which the merge puts above the default, and not in config.py.


Checking the resolved configuration

splent product:config --key session     # keys that contain "session"
splent product:config --feature cache   # keys a cache feature set
splent product:config --all             # Flask's own keys too

Each key shows the layer that set it, and secrets stay masked unless you pass --show-secrets. See product:config.


Setting the secret key

Development signs with a built-in key. In production, set SECRET_KEY in the .env to a long random value.

python -c "import secrets; print(secrets.token_urlsafe(48))"

The app refuses to start in production when the key is unset, blank, the <SET> placeholder or the development key. It warns when the key is shorter than 32 characters.


Signing with a key per purpose

By default SECRET_KEY signs everything, so a key that leaks through one use forges the others. Turn on SPLENT_DERIVED_KEYS and each purpose signs with its own key, derived from SECRET_KEY.

[tool.splent.config]
SPLENT_DERIVED_KEYS = true

The purposes are sessions, csrf, tokens and signed_urls, and a feature may add its own, such as notes.share. A feature signs with serializer(), which falls back to SECRET_KEY while the switch is off.

from splent_framework.keys import TOKENS, serializer

token = serializer(TOKENS, "invite-link").dumps({"email": address})
data = serializer(TOKENS, "invite-link").loads(token, max_age=3600)

Turning the switch on ends every session and every link signed before. Do it when you rotate keys anyway. Rotating SECRET_KEY rotates every derived key.


Production defaults

Every product gets these with no code. The generated .env examples list the common ones, commented out.

Tuning database connections

class ProductionConfig(BaseProd):
    SQLALCHEMY_ENGINE_OPTIONS = {**BaseProd.SQLALCHEMY_ENGINE_OPTIONS, "pool_size": 20}

The default is {"pool_pre_ping": True, "pool_recycle": 3600}. It replaces connections the database closed, so the first request after a restart or a quiet night does not fail. Spread it, as above, to change one option and keep the others.

Allowing larger uploads

MAX_CONTENT_LENGTH=64M

MAX_CONTENT_LENGTH is the largest request body the app reads, 16 MB by default. A bigger one gets a 413 before any view runs. It takes bytes or K, M or G. 0, none or unlimited removes the limit, and an unreadable value stops the app at start.

Securing cookies

SPLENT_TRUSTED_PROXIES=1

Cookies are Secure when the request arrived over HTTPS, so one image works on localhost and behind TLS. Behind a reverse proxy, set SPLENT_TRUSTED_PROXIES, or the app distrusts the proxy’s https. Cookies then lose Secure, links start with http:// and HSTS is never sent.

In production, SPLENT_COOKIE_SECURE=true makes every cookie Secure and the links built outside a request https. false does the opposite.

Sharing the session with another site

SESSION_COOKIE_SAMESITE=None

The session and remember-me cookies are SameSite=Lax. A client on another site that signs in with the cookie, such as an IDE plugin, needs None. SESSION_COOKIE_SAMESITE takes Lax, Strict or None, and anything else stops the app at start. SESSION_COOKIE_SECURE=true makes the session cookie always Secure, unless SPLENT_COOKIE_SECURE=false. The remember-me cookie follows unless you set its own REMEMBER_COOKIE_* variables.

Browsers refuse SameSite=None without Secure, and the app warns at start when that can happen. None also lets any site post with the user’s session, so every view exempt from CSRF that accepts the session needs its own defence, such as an Origin check. The log names those views.

Adjusting the security headers

Every response carries these headers unless a view or feature set one. Each setting is read from config.py first, then the environment.

Setting Default Notes
SPLENT_SECURITY_HEADERS true false sends none, for a proxy that does. nosniff is always sent
SPLENT_FRAME_OPTIONS SAMEORIGIN DENY, or off to send nothing
SPLENT_REFERRER_POLICY strict-origin-when-cross-origin Any Referrer-Policy value
SPLENT_PERMISSIONS_POLICY camera=(), microphone=(), geolocation=(), payment=(), usb=() The whole header value
SPLENT_NO_STORE_SIGNED_IN true no-store on pages for a signed-in user
SPLENT_HSTS_MAX_AGE 31536000 Over HTTPS only. 0 turns it off
SPLENT_HSTS_INCLUDE_SUBDOMAINS, SPLENT_HSTS_PRELOAD false preload needs includeSubDomains
SPLENT_CONTENT_SECURITY_POLICY none A policy sent as written
SPLENT_CSP_REPORT_ONLY false Sends that policy report-only
SPLENT_CSP auto The policy per page below. enforce, report-only or off

Letting a feature extend the Content-Security-Policy

Each HTML page gets a policy built from a base plus what the features drawn on it ask for, and only what your product approved. A script the page did not draw with its nonce does not run, so markup that slipped into a page cannot run code.

A product with a [tool.splent.csp] table, an empty one included, enforces it as Content-Security-Policy. product:create writes the table. A product without it gets Content-Security-Policy-Report-Only, which lists in the browser console what would be blocked without blocking it. Add the table once that list is empty.

A feature asks in splent.csp of its frontend/package.json (see feature:compile).

"csp": { "script-src": ["'wasm-unsafe-eval'"], "connect-src": ["https://models.example.org"] }

A feature with no frontend build, or one whose host the deployment chooses, asks from its code in init_feature. Pass a table, or a function of the app that returns one. Where its markup goes into another feature’s page, it calls mark_painted("turnstile") so that page gets its sources.

from splent_framework.managers import csp

csp.ask("turnstile", {"script-src": ["https://challenges.example.com"],
                      "frame-src": ["https://challenges.example.com"]})
csp.ask("stats", lambda app: {"connect-src": [app.config["STATS_ORIGIN"]]})

Your product approves in its pyproject.toml, one line per directive.

[tool.splent.csp]
sandbox = ["script-src 'wasm-unsafe-eval'", "connect-src https://models.example.org"]

A source is added only when both sides say so. product:validate lists the requests of splent.csp it does not approve, and the log names, once, a request from code it does not approve, with the line to add. 'unsafe-inline', 'unsafe-eval', 'unsafe-hashes', *, http:, and data: or blob: in script-src, are never added. A view’s own policy, or one in SPLENT_CONTENT_SECURITY_POLICY, wins.

The base, where <n> is the request’s nonce, is this.

default-src 'self'; script-src 'self' 'nonce-<n>'; style-src 'self' 'nonce-<n>';
style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self';
connect-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'self';
form-action 'self'; frame-ancestors 'self'

Caching assets

Asset URLs carry ?v=<mtime>, and a matching version is cached for a year as immutable. SPLENT_ASSET_MAX_AGE changes the seconds, 0 turns it off, and SPLENT_ASSET_VERSIONING=false stops adding ?v=. Source maps, package manifests, dotfiles and node_modules/ are never served.


Reading the product’s version

from splent_framework.configuration.configuration import get_app_version

version = get_app_version()

It returns the first line of WORKING_DIR/.version, so a deployment can stamp its tag. Otherwise it is the product package’s version, which in development is the [project].version of its pyproject.toml, or unknown.


Advanced

A key a feature declares in [tool.splent.config_schema] is resolved right after its inject_config, whatever that wrote. Weakest first, the schema’s default, another feature’s value, your [tool.splent.config] or config.py, then the environment. An environment value that only repeats what product:env --merge copied does not count as the deployment’s.


Reference

Framework defaults

Key Default A variable wins
SECRET_KEY A development key, required in production Yes
DEBUG True in development, False in production No
SQLALCHEMY_DATABASE_URI Built from the MARIADB_* variables No
SQLALCHEMY_ENGINE_OPTIONS {"pool_pre_ping": True, "pool_recycle": 3600} No
MAX_CONTENT_LENGTH 16 MB Yes
SPLENT_COOKIE_SECURE auto In production
SESSION_COOKIE_SAMESITE, REMEMBER_COOKIE_SAMESITE Lax Yes
SESSION_TYPE Not set. The feature that keeps the sessions sets it No
SPLENT_DERIVED_KEYS False Yes
TIMEZONE Europe/Madrid Yes
BABEL_DEFAULT_LOCALE, BABEL_SUPPORTED_LOCALES, BABEL_NEGOTIATE_FROM_HEADER en, the default alone, False. See i18n Yes
TASKS_MODE and the other TASKS_* Threads in the web process, sync in testing. See Background tasks Yes

Environment variables

The other variables the framework reads. The .env is generated and git-ignored, so never commit credentials. Env files says where each is set.

Variable Default Notes
SPLENT_APP Required The product to load
SPLENT_ENV None dev, test or prod. Picks the config class and the feature list. The CLI sets it
MARIADB_USER, MARIADB_PASSWORD, MARIADB_HOSTNAME, MARIADB_PORT default_user, default_password, localhost, 3306 A bad port stops the app
MARIADB_DATABASE, MARIADB_TEST_DATABASE default_db, default_test_db  
SPLENT_TRUSTED_PROXIES 0 Proxies in front of the app
SPLENT_TRUSTED_PROXY_HEADERS None prefix, port or both
SESSION_COOKIE_SECURE, REMEMBER_COOKIE_SECURE Unset See Sharing the session
REDIS_TASKS_URL, REDIS_URL None The task queue’s Redis when TASKS_REDIS_URL is empty
SPLENT_HEALTH_PATH, SPLENT_READY_PATH, SPLENT_READY_TIMEOUT /healthz, /readyz, 3 See Health probes
SPLENT_STORAGE_DIR The instance’s storage See Storage
RICH_TEXT_EMBED_HOSTS None See Embedded players
SPLENT_FEATURE_SOURCES None Set by feature:test, never by hand

See also


Back to top

splent. Distributed by an LGPL license v3. Contact us: drorganvidez@us.es