Documented browser sessions and protected routes.

2026-05-04 21:01:38 -04:00
parent d8cc18d242
commit d7f18ba962
6 changed files with 157 additions and 4 deletions
+1
@@ -7,6 +7,7 @@ A quick listing of all commands included with Owlbot.
| Command | Aliases | Description |
|---------|---------|-------------|
| `!about` | | Display bot version and project link. |
| `!connect` | | Send a private link to connect your browser to your Owncast chat user for protected Owlbot pages. |
## Clips
+18
@@ -67,6 +67,24 @@ Two permission gates are available:
When a permission check fails, a private system message is sent to the user explaining why. The command handler is not called. No error is raised; other viewers see nothing happen.
## Links to Protected Routes
If a command sends a user to one of your module's protected routes, use `ctx.session_url_for(path)` instead of `ctx.routes.url_for(path)`. The generated URL is tied to the user who ran the command and connects their browser before redirecting to the route.
```python
from owlbot.api import CommandContext, on_command
@on_command("settings", requires_moderator=True)
async def settings(ctx: CommandContext) -> None:
url = ctx.session_url_for("/settings")
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id,
f"Open settings: {url}",
)
```
Use plain `ctx.routes.url_for(path)` for public pages. See [Protected Routes](Modules-Routes#protected-routes) for route-level session and permission checks.
## Cooldowns
Cooldowns are global per-command, not per-user. Setting `cooldown=10` means the command can only fire once every 10 seconds regardless of who invokes it. This uses `time.monotonic()` internally so it's not affected by clock changes.
+22 -2
@@ -145,6 +145,12 @@ async def greet(ctx: CommandContext) -> None:
| `chat_event` | `ChatEvent` | The original chat event. |
| `user` | `User` | The user who invoked the command (shortcut to `chat_event.user`). |
### Methods
| Method | Description |
|--------|-------------|
| `session_url_for(path)` | Build a one-time connect URL for one of this module's routes, tied to the command user. Available in command handlers; use this when sending links to protected routes. |
All `ModuleContext` services are available directly on `ctx` (see [Proxied Properties](#proxied-properties) below).
## RouteContext
@@ -166,10 +172,24 @@ async def stats_page(ctx: RouteContext) -> dict:
|-------|------|-------------|
| `request` | `aiohttp.web.Request` | The aiohttp request object. Access query params, body, headers, path info, etc. |
| `match_info` | `dict[str, str]` | Captured path parameters from `{name}` patterns. Empty dict for plain routes. |
| `session` | `BrowserSession \| None` | Connected browser session for this request, if one was provided. Protected routes can use this to read the linked Owncast user. |
| `module` | `ModuleContext` | The full module context with all services. |
All `ModuleContext` services are available directly on `ctx` (see [Proxied Properties](#proxied-properties) below).
### BrowserSession
`ctx.session` is set when the browser has an active Owlbot session. Routes that use `requires_session=True`, `requires_authenticated=True`, or `requires_moderator=True` only run after the required session check passes, so `ctx.session` is available inside those handlers.
| Attribute | Type | Description |
|-----------|------|-------------|
| `user` | `User` | Owncast user linked to this browser session. |
| `expires_at` | `datetime` | When the session expires. |
| `is_authenticated` | `bool` | Whether the linked user is authenticated. |
| `is_moderator` | `bool` | Whether the linked user has the MODERATOR scope. |
For public routes, `ctx.session` may be `None`. Check it before reading session fields unless the route is protected. See [Protected Routes](Modules-Routes#protected-routes) for the route flags and session caveats.
## Proxied Properties
`EventContext`, `CommandContext`, and `RouteContext` all proxy the services from `ModuleContext` directly onto `ctx`. This means `ctx.owncast_client` and `ctx.module.owncast_client` return the same object. The full set of proxied properties:
@@ -179,7 +199,7 @@ All `ModuleContext` services are available directly on `ctx` (see [Proxied Prope
The only differences between the three context types are the handler-specific fields:
- **EventContext** adds `.event` (the triggering event) and propagation control.
- **CommandContext** adds `.command_event` (parsed command data) and convenience properties for command/args/user.
- **RouteContext** adds `.request` (the HTTP request).
- **CommandContext** adds `.command_event` (parsed command data), convenience properties for command/args/user, and `.session_url_for()`.
- **RouteContext** adds `.request` (the HTTP request) and `.session` (the connected browser session, if any).
Utility functions that need to work across handler types can accept `ModuleContext` directly, since all three contexts expose it via `.module`.
+63 -1
@@ -21,6 +21,9 @@ async def stats(ctx: RouteContext) -> dict:
| `path` | `str` | *(required)* | URL path relative to the module's namespace. |
| `methods` | `list[str] \| None` | `["GET"]` | HTTP methods to accept. |
| `streaming` | `bool` | `False` | If `True`, the handler runs without a timeout. Use for long-lived connections like SSE streams. The handler is responsible for its own keepalives, idle detection, and cleanup. |
| `requires_session` | `bool` | `False` | If `True`, the request must come from a browser connected to an Owncast user. |
| `requires_authenticated` | `bool` | `False` | If `True`, the connected Owncast user must be authenticated. Implies `requires_session`. |
| `requires_moderator` | `bool` | `False` | If `True`, the connected Owncast user must have the MODERATOR scope. Implies `requires_session`. |
A route that accepts multiple methods:
@@ -193,6 +196,62 @@ url = ctx.routes.url_for("/list")
This uses the `public_base_url` from config. If not set, it falls back to `owncast.url`. Set `public_base_url` if Owlbot is hosted on a different domain than your Owncast instance.
## Protected Routes
Routes that need to know which chat user is visiting can require an Owlbot browser session. A browser session is created when a viewer runs the built-in `!connect` command in chat and opens the private link, or when your module sends them a session URL with `ctx.session_url_for()` from a command.
`requires_session=True` only requires a linked browser session. Use `requires_authenticated=True` when the page should only be available to logged-in Owncast users. Use `requires_moderator=True` when it should only be available to moderators.
```python
from aiohttp import web
from markupsafe import escape
from owlbot.api import RouteContext, on_route
@on_route("/panel", requires_moderator=True)
async def moderator_panel(ctx: RouteContext) -> web.Response:
assert ctx.session is not None
# Display names are user-controlled. Escape them directly or render
# through Jinja templates so autoescape handles it.
name = escape(ctx.session.user.display_name)
return web.Response(
text=f"<h1>Moderator panel for {name}</h1>",
content_type="text/html",
)
```
If a request does not have a usable session, Owlbot returns a connection page explaining that the viewer should run `!connect` in chat. The handler is not called. If a session exists but fails the authentication or moderator check, Owlbot returns a permission page instead.
When a protected route runs, `ctx.session` contains the linked Owncast user snapshot:
| Property | Description |
|----------|-------------|
| `ctx.session.user` | The Owncast `User` tied to this browser session. |
| `ctx.session.is_authenticated` | Whether that user was authenticated in the latest user snapshot Owlbot has seen. |
| `ctx.session.is_moderator` | Whether that user had the MODERATOR scope in the latest user snapshot Owlbot has seen. |
| `ctx.session.expires_at` | When the browser session expires. |
### Sending Session URLs
Call `ctx.session_url_for(path)` from a command handler when you need to link to a protected page. Use it instead of `ctx.routes.url_for(path)` so Owlbot can link the browser session to the user who invoked the command. It accepts the same module-relative paths as `url_for()`, but returns a one-time `/owlbot/connect/<token>` URL for that user. When the user opens that link, Owlbot creates their browser session and redirects them to the route path you provided.
For example:
```python
from owlbot.api import CommandContext, on_command
@on_command("panel", requires_moderator=True)
async def send_panel_link(ctx: CommandContext) -> None:
url = ctx.session_url_for("/panel")
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id,
f"Open your moderator panel: {url}",
)
```
The path is module-relative (`"/panel"` or `"panel"`), not a full `/owlbot/...` path.
> **Warning:** Owncast does not send webhook events when a user's authentication or moderator status changes. Owlbot updates session user data when it sees a later event for that user, and sessions expire after one hour by default. This means permission changes may take up to the session lifetime, or the next user event, to affect already-connected browsers.
## Templates
Every module has access to a Jinja2 template environment via `ctx.templates`. Templates are loaded from the module's `templates/` directory first, then from Owlbot's core templates (which includes a Bootstrap `base.html`). Autoescape is enabled by default.
@@ -301,7 +360,7 @@ async def setup(ctx: ModuleContext) -> None:
| Method | Description |
|--------|-------------|
| `ctx.routes.register(path, handler, methods=..., streaming=...)` | Register a route. Accepts the same parameters as `@on_route`. Returns `RouteInfo`. |
| `ctx.routes.register(path, handler, methods=..., streaming=..., requires_session=..., requires_authenticated=..., requires_moderator=...)` | Register a route. Accepts the same parameters as `@on_route`. Returns `RouteInfo`. |
| `ctx.routes.unregister(path)` | Remove all handlers at the path. Returns `True` if found. |
| `ctx.routes.unregister(path, method=...)` | Remove only the handler covering that method. Returns `True` if found. |
| `ctx.routes.get(path)` | Get all handlers at the path. Returns `list[RouteInfo]`. |
@@ -321,3 +380,6 @@ async def setup(ctx: ModuleContext) -> None:
| `handler` | `RouteHandler` | The handler function. |
| `module_name` | `str` | Module that owns this route. |
| `streaming` | `bool` | Whether the handler bypasses the timeout. |
| `requires_session` | `bool` | Whether the route requires a connected browser session. |
| `requires_authenticated` | `bool` | Whether the route requires an authenticated Owncast user. |
| `requires_moderator` | `bool` | Whether the route requires the MODERATOR scope. |
+50
@@ -230,6 +230,56 @@ async def test_status_route(
Route URLs follow the `/owlbot/<module_name>/<path>` pattern used in production. Setting `module_pkg` also wires a real `ModuleTemplates` into `module_context.templates`, so route handlers that call `ctx.templates.render(...)` work without any extra setup.
### Testing Protected Routes
The `route_client` fixture includes Owlbot's connect routes and keeps cookies between requests, so protected route tests can use the same flow as a browser. Dispatch a command for a test user, redeem the generated session URL with `route_client`, then request the protected route:
```python
from aiohttp.test_utils import TestClient
from owlbot.api import CommandContext, EventType, ModuleContext, RouteContext
from owlbot.testing import make_chat_event, make_user
async def test_protected_route(
event_dispatcher,
module_context: ModuleContext,
route_client: TestClient,
) -> None:
session_urls: list[str] = []
async def handler(ctx: RouteContext) -> dict[str, str]:
assert ctx.session is not None
return {"user_id": ctx.session.user.id}
async def send_link(ctx: CommandContext) -> None:
session_urls.append(ctx.session_url_for("/secure"))
module_context.routes.register(
"/secure",
handler,
requires_authenticated=True,
)
module_context.commands.register("secure", send_link)
event = make_chat_event(
raw_body="!secure",
user=make_user(id="alice", is_authenticated=True),
)
await event_dispatcher.dispatch(EventType.CHAT, event)
session_url = session_urls[0]
path = session_url.removeprefix(module_context.config.public_base_url)
redeem = await route_client.get(path, allow_redirects=False)
assert redeem.status == 302
resp = await route_client.get("/owlbot/test_module/secure")
assert resp.status == 200
assert await resp.json() == {"user_id": "alice"}
```
Use `make_user(is_authenticated=True)` or `make_user(is_moderator=True)` to build users that pass the matching route guards. If you only need to assert that a protected page rejects an unconnected browser, request the route directly without redeeming a session URL first.
### Using Storage
The `storage` fixture provides a real `ModuleStorage` backed by an in-memory SQLite database. It behaves identically to production storage but does not write to disk, so each test starts with a clean database. A module that creates its schema at startup:
+3 -1
@@ -141,6 +141,8 @@ async def status_page(ctx: RouteContext) -> web.Response:
Returning a `dict` automatically serializes to JSON. A `web.Response` gives full control over the output. A `web.FileResponse` can serve static files directly from disk. See [HTTP Routes](Modules-Routes) for templates, URL building, and more.
Routes can also be protected so only connected, authenticated, or moderator Owncast users can access them. See [Protected Routes](Modules-Routes#protected-routes) for the browser session flow.
## Context
Every handler receives a context object (`ctx`) as its only argument. The context is the gateway to all of Owlbot's services: sending messages, querying the database, reading config, making HTTP requests, and more.
@@ -252,4 +254,4 @@ For more complex HTML, use `ctx.templates.render()` with Jinja2 templates from a
## Testing
Owlbot includes a pytest plugin with fixtures and helpers for testing modules without a running Owncast server. Tests run against the real dispatch pipeline with in-memory storage and recording stubs. See [Testing](Modules-Testing) for setup, available fixtures, and examples.
Owlbot includes a pytest plugin with fixtures and helpers for testing modules without a running Owncast server. Tests run against the real dispatch pipeline with in-memory storage and recording stubs. See [Testing](Modules-Testing) for setup, available fixtures, and examples.