Rename the import package app to fluksio

A wheel whose top-level module is `app` collides with anything else in a
user's venv, so the package that is about to be published takes the name
it is published under. Only the Python package moves; the repo, the
Docker WORKDIR and the compose project keep theirs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 21:48:05 +02:00
co-authored by Claude Opus 5
parent df05e3a62a
commit 640654bd66
170 changed files with 629 additions and 619 deletions
View File
+176
View File
@@ -0,0 +1,176 @@
import secrets
import warnings
from pathlib import Path
from typing import Annotated, Any, Literal
from pydantic import (
AnyUrl,
BeforeValidator,
EmailStr,
HttpUrl,
PostgresDsn,
computed_field,
model_validator,
)
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing_extensions import Self
def parse_cors(v: Any) -> list[str] | str:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",") if i.strip()]
elif isinstance(v, list | str):
return v
raise ValueError(v)
class Settings(BaseSettings):
model_config = SettingsConfigDict(
# Use top level .env file (one level above ./backend/)
env_file="../.env",
env_ignore_empty=True,
extra="ignore",
)
API_V1_STR: str = "/api/v1"
SECRET_KEY: str = secrets.token_urlsafe(32)
# 60 minutes * 24 hours * 8 days = 8 days
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
FRONTEND_HOST: str = "http://localhost:5173"
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
# Flows live on disk as a git repository; secrets stay outside it.
FLOWS_DIR: Path = Path("flow-data/flows")
SECRETS_FILE: Path = Path("flow-data/secrets.enc")
# Which failures reach which channel. Beside the flows, not in them:
# alerting is the deployment's concern, not any one flow's.
ALERTS_FILE: Path = Path("flow-data/alerts.json")
# Which dashboards each device shows. Beside the flows for the same reason
# alerting is: where a screen hangs is the deployment's concern rather than
# any one dashboard's.
PANELS_FILE: Path = Path("flow-data/panels.json")
# The MCP endpoint, and the OAuth server agents authenticate against. Off
# until someone asks for it: it opens client registration to the network.
MCP_ENABLED: bool = False
# Unauthenticated test-only endpoints (user seeding). Requires an explicit
# opt-in on top of ENVIRONMENT=local, so a deployment that merely kept the
# default environment never exposes them.
PRIVATE_API_ENABLED: bool = False
DOMAIN: str = "localhost"
OAUTH_PRIVATE_KEY_FILE: Path = Path("flow-data/oauth-key.pem")
# Written only when someone enrols this installation with a portal.
# Its absence is what keeps remote access off.
CLOUD_CONFIG_FILE: Path = Path("flow-data/cloud.json")
OAUTH_CODE_EXPIRE_SECONDS: int = 60
# Short, because an agent's token is a bearer secret held by a program
# rather than a person, and it can refresh unattended.
MCP_TOKEN_EXPIRE_MINUTES: int = 60
MCP_REFRESH_EXPIRE_DAYS: int = 30
FLOW_MAX_WORKERS: int = 4
# How long a python node may run before its worker is killed, unless the
# node sets its own. Long enough for a slow HTTP call, short enough that a
# runaway loop is not a wedged flow.
FLOW_NODE_TIMEOUT: float = 30.0
# How long the engine's own metrics, events and run records are kept.
OBS_RETENTION_DAYS: int = 30
# Without a Redis host the engine keeps its state in memory.
REDIS_HOST: str | None = None
REDIS_PORT: int = 6379
BACKEND_CORS_ORIGINS: Annotated[
list[AnyUrl] | str, BeforeValidator(parse_cors)
] = []
@computed_field # type: ignore[prop-decorator]
@property
def oauth_issuer(self) -> str:
"""Who issues MCP tokens — this app, on its API host.
Kept separate from the app's own URL because a hosted deployment can
later point agents at a different issuer without the resource server
changing: it validates whatever issuer it is configured to trust.
"""
scheme = "http" if self.ENVIRONMENT == "local" else "https"
return f"{scheme}://api.{self.DOMAIN}"
@computed_field # type: ignore[prop-decorator]
@property
def mcp_resource(self) -> str:
"""The resource an MCP token is issued for (RFC 8707)."""
return f"{self.oauth_issuer}/mcp"
@computed_field # type: ignore[prop-decorator]
@property
def all_cors_origins(self) -> list[str]:
return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [
self.FRONTEND_HOST
]
PROJECT_NAME: str
SENTRY_DSN: HttpUrl | None = None
POSTGRES_SERVER: str
POSTGRES_PORT: int = 5432
POSTGRES_USER: str
POSTGRES_PASSWORD: str = ""
POSTGRES_DB: str = ""
@computed_field # type: ignore[prop-decorator]
@property
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
return PostgresDsn.build(
scheme="postgresql+psycopg",
username=self.POSTGRES_USER,
password=self.POSTGRES_PASSWORD,
host=self.POSTGRES_SERVER,
port=self.POSTGRES_PORT,
path=self.POSTGRES_DB,
)
SMTP_TLS: bool = True
SMTP_SSL: bool = False
SMTP_PORT: int = 587
SMTP_HOST: str | None = None
SMTP_USER: str | None = None
SMTP_PASSWORD: str | None = None
EMAILS_FROM_EMAIL: EmailStr | None = None
EMAILS_FROM_NAME: str | None = None
@model_validator(mode="after")
def _set_default_emails_from(self) -> Self:
if not self.EMAILS_FROM_NAME:
self.EMAILS_FROM_NAME = self.PROJECT_NAME
return self
EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
@computed_field # type: ignore[prop-decorator]
@property
def emails_enabled(self) -> bool:
return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
EMAIL_TEST_USER: EmailStr = "test@example.com"
FIRST_SUPERUSER: EmailStr
FIRST_SUPERUSER_PASSWORD: str
def _check_default_secret(self, var_name: str, value: str | None) -> None:
if value == "changethis":
message = (
f'The value of {var_name} is "changethis", '
"for security, please change it, at least for deployments."
)
if self.ENVIRONMENT == "local":
warnings.warn(message, stacklevel=1)
else:
raise ValueError(message)
@model_validator(mode="after")
def _enforce_non_default_secrets(self) -> Self:
self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD)
self._check_default_secret(
"FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
)
return self
settings = Settings() # type: ignore
+35
View File
@@ -0,0 +1,35 @@
from sqlmodel import Session, create_engine, select
from fluksio import crud
from fluksio.core.config import settings
from fluksio.models import User, UserCreate
# A connection idle across a Postgres restart is dead but still pooled; the
# pre-ping spends a round trip to find out instead of failing the request.
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI), pool_pre_ping=True)
# make sure all SQLModel models are imported (app.models) before initializing DB
# otherwise, SQLModel might fail to initialize relationships properly
# for more details: https://github.com/fastapi/full-stack-fastapi-template/issues/28
def init_db(session: Session) -> None:
# Tables should be created with Alembic migrations
# But if you don't want to use migrations, create
# the tables un-commenting the next lines
# from sqlmodel import SQLModel
# This works because the models are already imported and registered from app.models
# SQLModel.metadata.create_all(engine)
user = session.exec(
select(User).where(User.email == settings.FIRST_SUPERUSER)
).first()
if not user:
user_in = UserCreate(
email=settings.FIRST_SUPERUSER,
password=settings.FIRST_SUPERUSER_PASSWORD,
is_superuser=True,
)
user = crud.create_user(session=session, user_create=user_in)
+232
View File
@@ -0,0 +1,232 @@
import base64
import uuid
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from typing import Any
import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
from pwdlib.hashers.bcrypt import BcryptHasher
from fluksio.core.config import settings
password_hash = PasswordHash(
(
Argon2Hasher(),
BcryptHasher(),
)
)
ALGORITHM = "HS256"
#: MCP tokens are signed with a keypair of their own, so the public half can be
#: published and the whole set revoked by rotating it — without logging anyone
#: out of the browser, and without the resource server needing the secret that
#: signs browser sessions.
OAUTH_ALGORITHM = "RS256"
#: The one scope an MCP token carries.
MCP_SCOPE = "mcp"
#: What a remote worker's credential says it is for. Its own audience, so an
#: agent's token cannot attach a worker and a worker's cannot call the API.
WORKER_SCOPE = "worker"
WORKER_AUDIENCE = "fluksio-worker"
#: What a paired wall panel's credential says it is for. Its own audience, so
#: the session decode refuses it outright and the scope check in ``deps`` is
#: the only door it fits.
PANEL_AUDIENCE = "fluksio-panel"
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
expire = datetime.now(timezone.utc) + expires_delta
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
# -----------------------------------------------------------------------------
# OAuth signing key
# -----------------------------------------------------------------------------
def _load_or_create_key() -> rsa.RSAPrivateKey:
"""The RSA key MCP tokens are signed with, generated on first use."""
path = settings.OAUTH_PRIVATE_KEY_FILE
if path.exists():
loaded = serialization.load_pem_private_key(path.read_bytes(), password=None)
if not isinstance(loaded, rsa.RSAPrivateKey):
raise TypeError(f"{path} is not an RSA private key")
return loaded
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(
key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
)
path.chmod(0o600)
return key
@lru_cache(maxsize=1)
def oauth_key() -> rsa.RSAPrivateKey:
return _load_or_create_key()
def public_jwks() -> dict[str, Any]:
"""The public half, for anything that wants to check a token itself."""
numbers = oauth_key().public_key().public_numbers()
def b64(value: int) -> str:
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
return {
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": OAUTH_ALGORITHM,
"kid": "fluksio-oauth",
"n": b64(numbers.n),
"e": b64(numbers.e),
}
]
}
def create_oauth_access_token(
user_id: uuid.UUID, client_id: uuid.UUID, expires_delta: timedelta
) -> str:
"""An access token for the MCP channel, told apart from a browser session.
The ``mcp`` claim is what the MCP endpoint checks: a perfectly valid token
from someone's browser is refused there, so agent traffic never arrives
looking like a person's.
"""
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"iss": settings.oauth_issuer,
"aud": settings.mcp_resource,
"iat": now,
"exp": now + expires_delta,
"mcp": True,
"client_id": str(client_id),
"scope": MCP_SCOPE,
}
return jwt.encode(
payload,
oauth_key().private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
),
algorithm=OAUTH_ALGORITHM,
headers={"kid": "fluksio-oauth"},
)
def create_worker_token(name: str, expires_delta: timedelta) -> str:
"""A credential a remote worker presents when it dials in.
Signed with the same keypair the agent tokens use, so the whole set can be
revoked by rotating one key, and told apart from them by its audience: a
worker's token grants no API access, and an agent's opens no worker
connection.
"""
now = datetime.now(timezone.utc)
payload = {
"sub": name,
"iss": settings.oauth_issuer,
"aud": WORKER_AUDIENCE,
"iat": now,
"exp": now + expires_delta,
"scope": WORKER_SCOPE,
}
return jwt.encode(
payload,
oauth_key().private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
),
algorithm=OAUTH_ALGORITHM,
headers={"kid": "fluksio-oauth"},
)
def decode_worker_token(token: str) -> dict[str, Any]:
"""Validate a worker credential. Raises ``InvalidTokenError`` if it does not."""
payload: dict[str, Any] = jwt.decode(
token,
oauth_key().public_key(),
algorithms=[OAUTH_ALGORITHM],
audience=WORKER_AUDIENCE,
issuer=settings.oauth_issuer,
)
return payload
def create_panel_token(
panel: str, user_id: uuid.UUID | str, expires_delta: timedelta
) -> str:
"""The credential a paired wall panel holds.
Signed with the app's own secret like a browser session, because it names a
person in exactly the same way: ``sub`` is the account that approved the
pairing, so everything the panel does is attributable to them. What keeps
it from being a full session is the ``panel`` claim — the request filter in
``fluksio.api.deps`` lets it reach only that panel's dashboards and the message
endpoints its widgets need.
Long-lived on purpose: a wall tablet is set up once and left running, and
it has no keyboard to log in again with.
"""
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"aud": PANEL_AUDIENCE,
"panel": panel,
"iat": now,
"exp": now + expires_delta,
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
def decode_panel_token(token: str) -> dict[str, Any]:
"""Validate a panel credential. Raises ``InvalidTokenError`` if it does not."""
payload: dict[str, Any] = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[ALGORITHM],
audience=PANEL_AUDIENCE,
)
return payload
def decode_oauth_token(token: str) -> dict[str, Any]:
"""Validate an MCP token. Raises ``InvalidTokenError`` if it does not hold."""
payload: dict[str, Any] = jwt.decode(
token,
oauth_key().public_key(),
algorithms=[OAUTH_ALGORITHM],
audience=settings.mcp_resource,
issuer=settings.oauth_issuer,
)
return payload
def verify_password(
plain_password: str, hashed_password: str
) -> tuple[bool, str | None]:
return password_hash.verify_and_update(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return password_hash.hash(password)