Configuration
Table of contents
- Introduction
- How the layers stack
- Reading a value
- Setting a value in your product
- Setting a value from a feature
- Overriding a value per deployment
- Checking the resolved configuration
- Setting the secret key
- Signing with a key per purpose
- Production defaults
- Reading the product’s version
- Advanced
- Reference
- 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
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_TYPEto 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 --mergecopies 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 inconfig.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_KEYrotates 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=NonewithoutSecure, and the app warns at start when that can happen.Nonealso 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 anOrigincheck. 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 itsinject_config, whatever that wrote. Weakest first, the schema’s default, another feature’s value, your[tool.splent.config]orconfig.py, then the environment. An environment value that only repeats whatproduct:env --mergecopied 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
- Feature settings, how features declare keys and products decide them.
product:config, every value and where it came from.- Application initialization, the full startup sequence.