Centralized session URL generation for commands and events.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 2m45s
CI / Tests (Python 3.13) (push) Successful in 2m53s
CI / Tests (Python 3.14) (push) Successful in 2m39s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-05-04 21:48:25 -04:00
parent 5443aa86ce
commit 9ac4a17ac8
10 changed files with 226 additions and 98 deletions
+1 -1
Submodule docs updated: d7f18ba962...d8ce4b4570
+12 -5
View File
@@ -123,6 +123,9 @@ class EventContext[E]:
# Shared services for this module. # Shared services for this module.
module: ModuleContext module: ModuleContext
# Event-scoped protected-route URL builder. Present for user-bearing events.
_session_url_for: Callable[[str], str] | None = field(default=None, repr=False)
# Shared state for propagation control. # Shared state for propagation control.
# All handlers for a single event dispatch share the same instance. # All handlers for a single event dispatch share the same instance.
_propagation: PropagationState = field(default_factory=PropagationState, repr=False) _propagation: PropagationState = field(default_factory=PropagationState, repr=False)
@@ -162,6 +165,13 @@ class EventContext[E]:
"""Module-scoped route API for URL building and route introspection.""" """Module-scoped route API for URL building and route introspection."""
return self.module.routes return self.module.routes
def session_url_for(self, path: str) -> str:
"""Build a connect URL for a module route for this event's user."""
if self._session_url_for is None:
msg = "session URLs are only available for events with a user"
raise RuntimeError(msg)
return self._session_url_for(path)
@property @property
def logger(self) -> logging.Logger: def logger(self) -> logging.Logger:
"""Module-scoped logger.""" """Module-scoped logger."""
@@ -222,9 +232,6 @@ class CommandContext:
# Shared services for this module. # Shared services for this module.
module: ModuleContext module: ModuleContext
# Command-invocation-scoped protected-route URL builder.
_session_url_for: Callable[[str], str] = field(repr=False)
@property @property
def command(self) -> str: def command(self) -> str:
"""The command name that was invoked (canonical name, not alias).""" """The command name that was invoked (canonical name, not alias)."""
@@ -311,8 +318,8 @@ class CommandContext:
return self.module.admin_client return self.module.admin_client
def session_url_for(self, path: str) -> str: def session_url_for(self, path: str) -> str:
"""Build a connect URL for a module route for this user.""" """Build a connect URL for a module route for this command's user."""
return self._session_url_for(path) return self.event_context.session_url_for(path)
@dataclass(slots=True) @dataclass(slots=True)
+1
View File
@@ -103,6 +103,7 @@ class ModuleLoader:
get_module_context=self._require_module_context, get_module_context=self._require_module_context,
handler_timeout=config.handler_timeout, handler_timeout=config.handler_timeout,
session_manager=self.session_manager, session_manager=self.session_manager,
public_base_url=config.public_base_url,
) )
self.route_dispatcher = RouteDispatcher( self.route_dispatcher = RouteDispatcher(
get_module_context=self._require_module_context, get_module_context=self._require_module_context,
+7 -35
View File
@@ -22,22 +22,20 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import math import math
import posixpath
import time import time
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from urllib.parse import unquote, urlsplit
from owlbot._version import __version__ from owlbot._version import __version__
from owlbot.api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark from owlbot.api.commands import CommandEvent, CommandHandler, CommandInfo, CommandMark
from owlbot.api.context import CommandContext, EventContext from owlbot.api.context import CommandContext, EventContext
from owlbot.api.event_types import ChatEvent from owlbot.api.event_types import ChatEvent
from owlbot.sessions import make_session_url_for
if TYPE_CHECKING: if TYPE_CHECKING:
from types import ModuleType from types import ModuleType
from owlbot.api.context import ModuleContext from owlbot.api.context import ModuleContext
from owlbot.api.event_types import User
from owlbot.api.owncast_client import OwncastClient from owlbot.api.owncast_client import OwncastClient
from owlbot.sessions import SessionManager from owlbot.sessions import SessionManager
@@ -547,16 +545,18 @@ class CommandDispatcher:
event_ctx: EventContext[ChatEvent] = EventContext( event_ctx: EventContext[ChatEvent] = EventContext(
event=event, event=event,
module=module_ctx, module=module_ctx,
_session_url_for=make_session_url_for(
session_manager=self._session_manager,
public_base_url=self._public_base_url,
module_name=command_info.module_name,
user=user,
),
) )
cmd_ctx = CommandContext( cmd_ctx = CommandContext(
command_event=cmd_event, command_event=cmd_event,
event_context=event_ctx, event_context=event_ctx,
module=module_ctx, module=module_ctx,
_session_url_for=self._make_session_url_for(
command_info.module_name,
user,
),
) )
try: try:
@@ -626,34 +626,6 @@ class CommandDispatcher:
unsanitized=True, unsanitized=True,
) )
def _make_session_url_for(
self,
module_name: str,
user: User,
) -> Callable[[str], str]:
"""Build the command-scoped session URL helper."""
session_manager = self._session_manager
public_base_url = self._public_base_url
def session_url_for(path: str) -> str:
if not path.startswith("/"):
path = "/" + path
destination_path = f"/owlbot/{module_name}{path}"
module_root = f"/owlbot/{module_name}"
decoded_path = unquote(urlsplit(destination_path).path)
normalized_path = posixpath.normpath(decoded_path)
if normalized_path != module_root and not normalized_path.startswith(
f"{module_root}/"
):
msg = "session destination must stay within the module namespace"
raise ValueError(msg)
token = session_manager.issue_connect_token(destination_path, user=user)
return f"{public_base_url}/owlbot/connect/{token}"
return session_url_for
class ModuleCommands: class ModuleCommands:
"""Module-scoped wrapper around CommandDispatcher. """Module-scoped wrapper around CommandDispatcher.
+27 -1
View File
@@ -34,6 +34,7 @@ from owlbot.api.event_types import (
log_event, log_event,
) )
from owlbot.api.events import EventHandler, EventMark, Priority from owlbot.api.events import EventHandler, EventMark, Priority
from owlbot.sessions import make_session_url_for
class HandlerEntry(NamedTuple): class HandlerEntry(NamedTuple):
@@ -51,6 +52,7 @@ if TYPE_CHECKING:
from types import ModuleType from types import ModuleType
from owlbot.api.context import ModuleContext from owlbot.api.context import ModuleContext
from owlbot.api.event_types import User
from owlbot.sessions import SessionManager from owlbot.sessions import SessionManager
logger = logging.getLogger("owlbot.events") logger = logging.getLogger("owlbot.events")
@@ -217,6 +219,7 @@ class EventDispatcher:
get_module_context: Callable[[str], ModuleContext], get_module_context: Callable[[str], ModuleContext],
handler_timeout: float, handler_timeout: float,
session_manager: SessionManager, session_manager: SessionManager,
public_base_url: str,
) -> None: ) -> None:
"""Initialize the event dispatcher. """Initialize the event dispatcher.
@@ -227,12 +230,15 @@ class EventDispatcher:
:param get_module_context: Callable that looks up a :param get_module_context: Callable that looks up a
ModuleContext by module name. ModuleContext by module name.
:param handler_timeout: Timeout for individual handlers in seconds. :param handler_timeout: Timeout for individual handlers in seconds.
:param session_manager: Session manager used for browser connect links.
:param public_base_url: Public base URL for generated connect links.
""" """
self._handler_registry = EventRegistry() self._handler_registry = EventRegistry()
self._command_dispatch = command_dispatch self._command_dispatch = command_dispatch
self._get_module_context = get_module_context self._get_module_context = get_module_context
self._handler_timeout = handler_timeout self._handler_timeout = handler_timeout
self._session_manager = session_manager self._session_manager = session_manager
self._public_base_url = public_base_url
def register( def register(
self, self,
@@ -356,7 +362,13 @@ class EventDispatcher:
) )
break break
await self._call_handler(handler, event, module_name, propagation) await self._call_handler(
handler,
event,
module_name,
propagation,
user=user,
)
else: else:
logger.debug("No handlers registered for event type: %s", event_type) logger.debug("No handlers registered for event type: %s", event_type)
@@ -381,6 +393,8 @@ class EventDispatcher:
event: Event, event: Event,
module_name: str, module_name: str,
propagation: PropagationState, propagation: PropagationState,
*,
user: User | None,
) -> None: ) -> None:
"""Call a single handler with timeout enforcement. """Call a single handler with timeout enforcement.
@@ -388,15 +402,27 @@ class EventDispatcher:
:param event: The event to pass to the handler. :param event: The event to pass to the handler.
:param module_name: The module that owns this handler. :param module_name: The module that owns this handler.
:param propagation: Shared propagation state for this dispatch cycle. :param propagation: Shared propagation state for this dispatch cycle.
:param user: User resolved from the event, if the event carries one.
""" """
handler_name = handler.__name__ handler_name = handler.__name__
logger.debug("Calling handler: %s from module: %s", handler_name, module_name) logger.debug("Calling handler: %s from module: %s", handler_name, module_name)
module_ctx = self._get_module_context(module_name) module_ctx = self._get_module_context(module_name)
session_url_for = (
make_session_url_for(
session_manager=self._session_manager,
public_base_url=self._public_base_url,
module_name=module_name,
user=user,
)
if user is not None
else None
)
ctx: EventContext[Event] = EventContext( ctx: EventContext[Event] = EventContext(
event=event, event=event,
module=module_ctx, module=module_ctx,
_session_url_for=session_url_for,
_propagation=propagation, _propagation=propagation,
) )
+40
View File
@@ -18,6 +18,7 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import posixpath
import secrets import secrets
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@@ -25,6 +26,8 @@ from typing import TYPE_CHECKING
from urllib.parse import unquote, urlsplit from urllib.parse import unquote, urlsplit
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable
from owlbot.api.event_types import User from owlbot.api.event_types import User
SESSION_COOKIE_NAME = "owlbot_session" SESSION_COOKIE_NAME = "owlbot_session"
@@ -291,3 +294,40 @@ class SessionManager:
self._session_ids_by_user_id.setdefault(session.user.id, set()).add( self._session_ids_by_user_id.setdefault(session.user.id, set()).add(
session.session_id session.session_id
) )
def make_session_url_for(
*,
session_manager: SessionManager,
public_base_url: str,
module_name: str,
user: User,
) -> Callable[[str], str]:
"""Build a module-scoped protected-route URL helper for a user.
:param session_manager: Session manager that issues connect tokens.
:param public_base_url: Public base URL for generated connect links.
:param module_name: Module namespace the destination must stay within.
:param user: User the generated connect token should link to.
:return: Function that converts a module-relative path into a connect URL.
"""
public_base_url = public_base_url.rstrip("/")
def session_url_for(path: str) -> str:
if not path.startswith("/"):
path = "/" + path
destination_path = f"/owlbot/{module_name}{path}"
module_root = f"/owlbot/{module_name}"
decoded_path = unquote(urlsplit(destination_path).path)
normalized_path = posixpath.normpath(decoded_path)
if normalized_path != module_root and not normalized_path.startswith(
f"{module_root}/"
):
msg = "session destination must stay within the module namespace"
raise ValueError(msg)
token = session_manager.issue_connect_token(destination_path, user=user)
return f"{public_base_url}/owlbot/connect/{token}"
return session_url_for
+2
View File
@@ -316,6 +316,7 @@ def event_dispatcher(
session_manager: SessionManager, session_manager: SessionManager,
command_dispatcher: CommandDispatcher, command_dispatcher: CommandDispatcher,
handler_timeout: float, handler_timeout: float,
config: Config,
) -> EventDispatcher: ) -> EventDispatcher:
"""Build an EventDispatcher that resolves contexts via ``_context_registry``.""" """Build an EventDispatcher that resolves contexts via ``_context_registry``."""
return EventDispatcher( return EventDispatcher(
@@ -323,6 +324,7 @@ def event_dispatcher(
get_module_context=_make_context_lookup(_context_registry), get_module_context=_make_context_lookup(_context_registry),
handler_timeout=handler_timeout, handler_timeout=handler_timeout,
session_manager=session_manager, session_manager=session_manager,
public_base_url=config.public_base_url,
) )
+7 -56
View File
@@ -999,56 +999,6 @@ class TestCommandDispatcherDispatch:
assert connect_token.destination_path == "/owlbot/test_module/panel" assert connect_token.destination_path == "/owlbot/test_module/panel"
assert connect_token.user.id == user.id assert connect_token.user.id == user.id
def test_command_session_url_for_accepts_module_relative_path(
self, module_context: ModuleContext
) -> None:
"""Command session URLs add the module namespace to relative paths."""
session_manager = SessionManager()
dispatcher, _, _ = self._make_dispatcher(
module_context,
session_manager=session_manager,
public_base_url="https://example.com",
)
session_url_for = dispatcher._make_session_url_for(
module_context.module_name,
make_user(id="alice"),
)
url = session_url_for("panel")
token = url.rsplit("/", 1)[-1]
connect_token = session_manager.get_connect_token(token)
assert connect_token is not None
assert connect_token.destination_path == "/owlbot/test_module/panel"
@pytest.mark.parametrize(
"path",
[
pytest.param("../panel", id="relative-parent"),
pytest.param("/../panel", id="absolute-parent"),
pytest.param("/../../admin", id="multi-parent"),
pytest.param("nested/../../other", id="nested-parent"),
pytest.param("/%2e%2e/panel", id="encoded-parent"),
],
)
def test_command_session_url_for_rejects_paths_outside_module_namespace(
self, module_context: ModuleContext, path: str
) -> None:
"""Module-provided session paths cannot escape their namespace."""
session_manager = SessionManager()
dispatcher, _, _ = self._make_dispatcher(
module_context,
session_manager=session_manager,
public_base_url="https://example.com",
)
session_url_for = dispatcher._make_session_url_for(
module_context.module_name,
make_user(id="alice"),
)
with pytest.raises(ValueError, match="module namespace"):
session_url_for(path)
async def test_unregistered_builtin_does_not_shadow_later_module_command( async def test_unregistered_builtin_does_not_shadow_later_module_command(
self, module_context: ModuleContext self, module_context: ModuleContext
) -> None: ) -> None:
@@ -1305,11 +1255,7 @@ class TestCommandContext:
chat_event=event, chat_event=event,
) )
event_ctx: EventContext[ChatEvent] = EventContext( event_ctx: EventContext[ChatEvent] = EventContext(
event=event, module=module_ctx event=event,
)
cmd_ctx = CommandContext(
command_event=cmd_event,
event_context=event_ctx,
module=module_ctx, module=module_ctx,
_session_url_for=( _session_url_for=(
session_url_for session_url_for
@@ -1317,6 +1263,11 @@ class TestCommandContext:
else lambda path: f"https://example.com{path}" else lambda path: f"https://example.com{path}"
), ),
) )
cmd_ctx = CommandContext(
command_event=cmd_event,
event_context=event_ctx,
module=module_ctx,
)
return cmd_ctx, cmd_event, event_ctx return cmd_ctx, cmd_event, event_ctx
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -1379,7 +1330,7 @@ class TestCommandContext:
assert cmd_ctx.event_context is event_ctx assert cmd_ctx.event_context is event_ctx
def test_session_url_for(self, module_context: ModuleContext) -> None: def test_session_url_for(self, module_context: ModuleContext) -> None:
"""ctx.session_url_for() delegates to the command-scoped URL builder.""" """ctx.session_url_for() delegates to the underlying event context."""
cmd_ctx, _, _ = self._make_command_context( cmd_ctx, _, _ = self._make_command_context(
module_context, module_context,
session_url_for=lambda path: f"https://example.com{path}", session_url_for=lambda path: f"https://example.com{path}",
+69
View File
@@ -45,6 +45,7 @@ from owlbot.sessions import SessionManager
from owlbot.testing import ( from owlbot.testing import (
make_chat_event, make_chat_event,
make_name_changed_event, make_name_changed_event,
make_stream_started_event,
make_user, make_user,
make_user_joined_event, make_user_joined_event,
) )
@@ -65,6 +66,7 @@ def _make_dispatcher(
*, *,
handler_timeout: float, handler_timeout: float,
session_manager: SessionManager | None = None, session_manager: SessionManager | None = None,
public_base_url: str = "https://example.com",
) -> EventDispatcher: ) -> EventDispatcher:
"""Build an EventDispatcher with a default session manager for tests.""" """Build an EventDispatcher with a default session manager for tests."""
return EventDispatcher( return EventDispatcher(
@@ -72,6 +74,7 @@ def _make_dispatcher(
get_module_context=get_module_context, get_module_context=get_module_context,
handler_timeout=handler_timeout, handler_timeout=handler_timeout,
session_manager=session_manager or SessionManager(), session_manager=session_manager or SessionManager(),
public_base_url=public_base_url,
) )
@@ -429,6 +432,48 @@ class TestEventDispatcherDispatch:
assert len(received) == 1 assert len(received) == 1
assert received[0] is event assert received[0] is event
async def test_user_event_context_can_issue_session_url(
self, module_context: ModuleContext
) -> None:
"""Event contexts for user-bearing events can issue protected route URLs."""
session_manager = SessionManager()
captured_url = ""
user = make_user(id="user-123", is_authenticated=True)
async def handler(ctx: EventContext[Any]) -> None:
nonlocal captured_url
captured_url = ctx.session_url_for("/panel")
async def command_dispatch(event: ChatEvent) -> None:
pass
dispatcher = _make_dispatcher(
command_dispatch=command_dispatch,
get_module_context=lambda _name: module_context,
handler_timeout=5.0,
session_manager=session_manager,
public_base_url="https://example.com",
)
dispatcher.register(
handler, (EventType.USER_JOINED,), module_context.module_name
)
await dispatcher.dispatch(
EventType.USER_JOINED,
make_user_joined_event(
user=user,
client_id=1,
event_id="evt-001",
),
)
token = captured_url.rsplit("/", 1)[-1]
connect_token = session_manager.get_connect_token(token)
assert captured_url.startswith("https://example.com/owlbot/connect/")
assert connect_token is not None
assert connect_token.destination_path == "/owlbot/test_module/panel"
assert connect_token.user.id == user.id
async def test_priority_order(self, module_context: ModuleContext) -> None: async def test_priority_order(self, module_context: ModuleContext) -> None:
"""Handlers execute in priority order: HIGH before NORMAL before LOW.""" """Handlers execute in priority order: HIGH before NORMAL before LOW."""
call_order: list[str] = [] call_order: list[str] = []
@@ -1107,6 +1152,30 @@ class TestEventContext:
ctx: EventContext[ChatEvent] = EventContext(event=event, module=module_context) ctx: EventContext[ChatEvent] = EventContext(event=event, module=module_context)
assert ctx.event is event assert ctx.event is event
def test_session_url_for_delegates_to_event_builder(
self, module_context: ModuleContext
) -> None:
"""ctx.session_url_for() delegates to the event-scoped URL builder."""
ctx: EventContext[ChatEvent] = EventContext(
event=make_chat_event(),
module=module_context,
_session_url_for=lambda path: f"https://example.com{path}",
)
assert ctx.session_url_for("/panel") == "https://example.com/panel"
def test_session_url_for_without_user_event_raises(
self, module_context: ModuleContext
) -> None:
"""Events without a user do not have a session URL builder."""
ctx: EventContext[Any] = EventContext(
event=make_stream_started_event(),
module=module_context,
)
with pytest.raises(RuntimeError, match="events with a user"):
ctx.session_url_for("/panel")
def test_propagation_stopped_default(self, module_context: ModuleContext) -> None: def test_propagation_stopped_default(self, module_context: ModuleContext) -> None:
"""propagation_stopped is False by default.""" """propagation_stopped is False by default."""
ctx: EventContext[ChatEvent] = EventContext( ctx: EventContext[ChatEvent] = EventContext(
+60
View File
@@ -24,6 +24,7 @@ from owlbot.sessions import (
DEFAULT_CONNECT_DESTINATION_PATH, DEFAULT_CONNECT_DESTINATION_PATH,
ConnectTokenRedemptionError, ConnectTokenRedemptionError,
SessionManager, SessionManager,
make_session_url_for,
) )
from owlbot.testing import make_user from owlbot.testing import make_user
@@ -286,3 +287,62 @@ class TestSessionManager:
await manager.close() await manager.close()
assert manager._cleanup_task is None assert manager._cleanup_task is None
class TestMakeSessionUrlFor:
"""Tests for the shared protected-route URL builder."""
def test_accepts_module_relative_path(self) -> None:
"""Session URLs add the module namespace to relative paths."""
session_manager = SessionManager()
session_url_for = make_session_url_for(
session_manager=session_manager,
public_base_url="https://example.com",
module_name="test_module",
user=make_user(id="alice"),
)
url = session_url_for("panel")
token = url.rsplit("/", 1)[-1]
connect_token = session_manager.get_connect_token(token)
assert url.startswith("https://example.com/owlbot/connect/")
assert connect_token is not None
assert connect_token.destination_path == "/owlbot/test_module/panel"
def test_strips_public_base_url_trailing_slash(self) -> None:
"""Generated connect URLs do not include a double slash before /owlbot."""
session_manager = SessionManager()
session_url_for = make_session_url_for(
session_manager=session_manager,
public_base_url="https://example.com/",
module_name="test_module",
user=make_user(id="alice"),
)
assert session_url_for("/panel").startswith(
"https://example.com/owlbot/connect/"
)
@pytest.mark.parametrize(
"path",
[
pytest.param("../panel", id="relative-parent"),
pytest.param("/../panel", id="absolute-parent"),
pytest.param("/../../admin", id="multi-parent"),
pytest.param("nested/../../other", id="nested-parent"),
pytest.param("/%2e%2e/panel", id="encoded-parent"),
],
)
def test_rejects_paths_outside_module_namespace(self, path: str) -> None:
"""Module-provided session paths cannot escape their namespace."""
session_manager = SessionManager()
session_url_for = make_session_url_for(
session_manager=session_manager,
public_base_url="https://example.com",
module_name="test_module",
user=make_user(id="alice"),
)
with pytest.raises(ValueError, match="module namespace"):
session_url_for(path)