From 328f4303090df1cca88777900da8af5d15e28314 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Sun, 2 Aug 2026 22:49:38 +0900 Subject: [PATCH 1/4] Rename parsing error to be more reusable --- src/config/logging.py | 4 ++-- src/config/parse.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/config/logging.py b/src/config/logging.py index 43a3071..b77dd08 100644 --- a/src/config/logging.py +++ b/src/config/logging.py @@ -2,7 +2,7 @@ from typing import Any from dataclasses import dataclass import logging -from .parse import assert_key_of_type, ConfigParseError +from .parse import assert_key_of_type, ParseError LOG_LEVELS = { 'critical': logging.CRITICAL, @@ -25,7 +25,7 @@ class Logging: assert_key_of_type(config, 'level', str) log_level_setting = config['level'] if log_level_setting not in LOG_LEVELS: - raise ConfigParseError( + raise ParseError( ['level'], f'unknown log level "{log_level_setting}". Expected one of: {", ".join(LOG_LEVELS.keys())}' ) diff --git a/src/config/parse.py b/src/config/parse.py index c50df3f..2faea78 100644 --- a/src/config/parse.py +++ b/src/config/parse.py @@ -1,7 +1,7 @@ from typing import Any, TypeVar, Callable -class ConfigParseError(Exception): +class ParseError(Exception): """An error for when parsing a config fails""" def __init__(self, keypath: list[str], issue: str): @@ -14,10 +14,10 @@ class ConfigParseError(Exception): def assert_key_of_type(config: dict[str, Any], key: str, kind: Any): if key not in config: - raise ConfigParseError([key], 'missing') + raise ParseError([key], 'missing') if not isinstance(config[key], kind): - raise ConfigParseError([key], f'type of "{key}" was {type(config[key])}, expected {kind}') + raise ParseError([key], f'type of "{key}" was {type(config[key])}, expected {kind}') T = TypeVar('T') @@ -27,5 +27,5 @@ def parse_nested_config(config: dict[str, Any], key: str, parse: Callable[[dict[ assert_key_of_type(config, key, dict) try: return parse(config[key]) - except ConfigParseError as e: - raise ConfigParseError([key, *e.keypath], e.issue) + except ParseError as e: + raise ParseError([key, *e.keypath], e.issue) From 387e2fa4902d0c7f9e238c9c46e0b5389d49e58d Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Sun, 2 Aug 2026 22:50:05 +0900 Subject: [PATCH 2/4] Add an auth service for minting and decoding JWTs --- config.example.json | 3 +++ derivation.nix | 2 +- shell.nix | 2 ++ src/config/__init__.py | 13 +++++++++--- src/config/auth.py | 16 +++++++++++++++ src/services/auth.py | 45 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 src/config/auth.py create mode 100644 src/services/auth.py diff --git a/config.example.json b/config.example.json index a994526..b97e6a3 100644 --- a/config.example.json +++ b/config.example.json @@ -11,5 +11,8 @@ "database": { "url": "sqlite://", "echo": false + }, + "auth": { + "ed25519_private_key": "A base64 encoded EdDSA Private Key" } } diff --git a/derivation.nix b/derivation.nix index e5cc219..639f015 100644 --- a/derivation.nix +++ b/derivation.nix @@ -3,7 +3,7 @@ with python313Packages; buildPythonApplication { pname = "cereal"; version = "0.0.1"; - propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi]; + propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi pyjwt cryptography]; src = ./.; pyproject = true; build-system = [setuptools]; diff --git a/shell.nix b/shell.nix index 2a39902..50007eb 100644 --- a/shell.nix +++ b/shell.nix @@ -6,6 +6,8 @@ let waitress sqlalchemy argon2-cffi + pyjwt + cryptography ]); in with pkgs; diff --git a/src/config/__init__.py b/src/config/__init__.py index 32b37b3..dab2591 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -4,6 +4,7 @@ from typing import Any from .logging import Logging from .email import Email +from .auth import Auth from .database import Database from .parse import assert_key_of_type, parse_nested_config @@ -15,19 +16,25 @@ class Config: logging: Logging email: Email database: Database + auth: Auth @classmethod def from_dict(cls, config: dict[str, Any]) -> 'Config': - assert_key_of_type(config, 'logging', dict) - assert_key_of_type(config, 'email', dict) assert_key_of_type(config, 'host', str) assert_key_of_type(config, 'port', int) + log_config = parse_nested_config(config, 'logging', Logging.from_dict) email_config = parse_nested_config(config, 'email', Email.from_dict) db_config = parse_nested_config(config, 'database', Database.from_dict) + auth_config = parse_nested_config(config, 'auth', Auth.from_dict) return Config( - host=config['host'], port=config['port'], email=email_config, logging=log_config, database=db_config + host=config['host'], + port=config['port'], + email=email_config, + logging=log_config, + database=db_config, + auth=auth_config, ) diff --git a/src/config/auth.py b/src/config/auth.py new file mode 100644 index 0000000..dbc5f33 --- /dev/null +++ b/src/config/auth.py @@ -0,0 +1,16 @@ +from base64 import b64decode +from typing import Any +from src.config.parse import assert_key_of_type +from src.utils.secret import SecretBox +from dataclasses import dataclass + + +@dataclass +class Auth: + ed25519_private_key: SecretBox[bytes] + + @classmethod + def from_dict(cls, config: dict[str, Any]) -> 'Auth': + assert_key_of_type(config, 'ed25519_private_key', str) + private_key = b64decode(config['ed25519_private_key']) + return Auth(ed25519_private_key=SecretBox(private_key)) diff --git a/src/services/auth.py b/src/services/auth.py new file mode 100644 index 0000000..ec38c2c --- /dev/null +++ b/src/services/auth.py @@ -0,0 +1,45 @@ +from src.config.parse import assert_key_of_type, ParseError +from typing import Any +from dataclasses import dataclass, asdict +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +import jwt + +from src.services.users import User +from src.config.auth import Auth as AuthConfig + +JWT = str + + +@dataclass +class Claims: + id: int + + @classmethod + def from_user(cls, user: User) -> 'Claims': + return Claims(id=user.id) + + @classmethod + def from_dict(cls, claims: dict[str, Any]) -> 'Claims': + assert_key_of_type(claims, 'id', int) + return Claims(id=claims['id']) + + +class AuthService: + def __init__(self, config: AuthConfig): + self._private_key = Ed25519PrivateKey.from_private_bytes(config.ed25519_private_key.expose_secret()) + self._public_key = self._private_key.public_key() + + def mint_jwt(self, user: User) -> JWT: + claims = Claims.from_user(user) + return jwt.encode(asdict(claims), self._private_key, algorithm='EdDSA') + + def validate_token(self, token: JWT) -> Claims | None: + try: + claims = jwt.decode(token, key=self._public_key, algorithms=['EdDSA']) + except jwt.InvalidTokenError: + return None + + try: + return Claims.from_dict(claims) + except ParseError: + return None From 7b6d351282c1dbfd4ebd23524354fce4d740c835 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Sun, 2 Aug 2026 22:50:34 +0900 Subject: [PATCH 3/4] Move the notification service into the services directory --- src/main.py | 2 +- src/notifications/__init__.py | 0 src/notifications/data.py | 38 ----------------- .../service.py => services/notifications.py} | 42 +++++++++++++++++-- 4 files changed, 39 insertions(+), 43 deletions(-) delete mode 100644 src/notifications/__init__.py delete mode 100644 src/notifications/data.py rename src/{notifications/service.py => services/notifications.py} (50%) diff --git a/src/main.py b/src/main.py index d046769..3249a8b 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -from src.notifications.service import make_notification_service +from src.services.notifications import NotificationService, get_notification_service from src.services.email import get_service as get_email_service from src.services.users import get_service as get_user_service from src.infra.db import get_database diff --git a/src/notifications/__init__.py b/src/notifications/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/notifications/data.py b/src/notifications/data.py deleted file mode 100644 index a2633c8..0000000 --- a/src/notifications/data.py +++ /dev/null @@ -1,38 +0,0 @@ -from dataclasses import dataclass, field - -URL = str - - -@dataclass -class NotificationAction: - # A unique identifier for the action - action: str - # The title to show with the action - title: str - # Where to navigate to when clicked. - navigate: URL | None = None - - -@dataclass -class Notification: - """ - Details to show in a notification - - Much of the data here is intended to match the arguments to the [showNotification](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification) - Web API, but this content is ideally displayable in other contexts (for example in Email or RSS) - """ - - # The main title content to show - title: str - # The content of the notification - content: str - - # The UNIX timestamp in milliseconds since the epoch associated with this notification - timestamp: int - # A list of actions included with this notification - actions: list[NotificationAction] = field(default_factory=list) - # The location of an icon to show as the icon for this notification - icon: URL | None = None - - # The location of an image associated with this notification - image: URL | None = None diff --git a/src/notifications/service.py b/src/services/notifications.py similarity index 50% rename from src/notifications/service.py rename to src/services/notifications.py index ed64cce..67e8b12 100644 --- a/src/notifications/service.py +++ b/src/services/notifications.py @@ -2,11 +2,45 @@ import abc from typing import Any from src.services.email import EmailService -from src.config import Config -from .data import Notification +from dataclasses import dataclass, field -# MUSTFIX: Needs real users! +URL = str + + +@dataclass +class NotificationAction: + # A unique identifier for the action + action: str + # The title to show with the action + title: str + # Where to navigate to when clicked. + navigate: URL | None = None + + +@dataclass +class Notification: + """ + Details to show in a notification + + Much of the data here is intended to match the arguments to the [showNotification](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification) + Web API, but this content is ideally displayable in other contexts (for example in Email or RSS) + """ + + # The main title content to show + title: str + # The content of the notification + content: str + + # The UNIX timestamp in milliseconds since the epoch associated with this notification + timestamp: int + # A list of actions included with this notification + actions: list[NotificationAction] = field(default_factory=list) + # The location of an icon to show as the icon for this notification + icon: URL | None = None + + # The location of an image associated with this notification + image: URL | None = None class NotificationSender(abc.ABC): @@ -48,7 +82,7 @@ class EmailNotifier(NotificationSender): pass -def make_notification_service(email_service: EmailService): +def get_notification_service(email_service: EmailService): service = NotificationService() service.register_sender(EmailNotifier(email_service)) From 72ece8af72d3d7e21b84c041a0a3c02331e49168 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Sun, 2 Aug 2026 22:50:47 +0900 Subject: [PATCH 4/4] Change how services are provided so that they don't go via "extensions" --- src/main.py | 35 ++++++++++++++++++++++++----------- src/services/email.py | 2 +- src/services/users.py | 4 ---- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/main.py b/src/main.py index 3249a8b..cfc48d8 100644 --- a/src/main.py +++ b/src/main.py @@ -1,8 +1,10 @@ #!/usr/bin/env python from src.services.notifications import NotificationService, get_notification_service -from src.services.email import get_service as get_email_service -from src.services.users import get_service as get_user_service +from src.services.email import EmailService, get_email_service +from src.services.auth import AuthService +from src.services.users import UserService +from dataclasses import dataclass from src.infra.db import get_database from src.infra.users import UserRepoImpl from waitress import serve @@ -15,20 +17,31 @@ from src.config import parse_config, Config logger = logging.getLogger(__name__) +@dataclass +class AppServices: + users: UserService + auth: AuthService + email: EmailService + notifications: NotificationService + + +class MyFlask(Flask): + services: AppServices + + def create_app(name: str, config: Config) -> Flask: logging.basicConfig(level=config.logging.level, format=('%(asctime)s %(levelname)s [%(name)s] %(message)s')) - app = Flask(name) - - email_service = get_email_service(config.email) - notification_service = make_notification_service(email_service) + app = MyFlask(name) database = get_database(config.database) user_repo = UserRepoImpl(database) - user_service = get_user_service(user_repo) - - app.extensions['email'] = email_service - app.extensions['notifications'] = notification_service - app.extensions['user_service'] = user_service + email_service = get_email_service(config.email) + app.services = AppServices( + users=UserService(user_repo), + auth=AuthService(config.auth), + email=email_service, + notifications=get_notification_service(email_service), + ) if config.host: app.config['SERVER_NAME'] = config.host diff --git a/src/services/email.py b/src/services/email.py index dc51ad0..918bd28 100644 --- a/src/services/email.py +++ b/src/services/email.py @@ -42,5 +42,5 @@ class BirdEmailServiceImpl(EmailService): return response.ok -def get_service(config: EmailConfig) -> EmailService: +def get_email_service(config: EmailConfig) -> EmailService: return BirdEmailServiceImpl(config) diff --git a/src/services/users.py b/src/services/users.py index 9b4a0c2..3132f33 100644 --- a/src/services/users.py +++ b/src/services/users.py @@ -96,7 +96,3 @@ class UserService: user = self._repo.get_user_by_id(user_id) if user: return user.to_profile() - - -def get_service(repo: UserRepo) -> UserService: - return UserService(repo)