Events and Activity Log¶
pachifast can push events to browsers over a WebSocket (who logged in, what changed, which requests were made) and can record every write request in an activity log. Both features are off by default, and both are aimed mainly at administrators.
This page covers:
- enabling the event stream and the shape of its messages;
- authenticating a WebSocket with one-time tickets;
- who receives which event, and the close codes you must handle;
- the activity log and its endpoints;
- a complete browser client.
Enable the event stream¶
PACHIFAST_EVENTS_BROADCAST_ENABLED=true
This mounts two routes under /api/core/events:
| Route | Purpose |
|---|---|
POST /api/core/events/ticket |
Authenticated with Authorization: Bearer <access token>. Returns a one-time ticket. |
GET /api/core/events/ws |
The WebSocket endpoint. |
When the setting is off, neither route exists and a WebSocket handshake receives HTTP 403 whatever credentials it carries.
Message format¶
Every event is a JSON text frame with the same envelope:
{
"type": "login",
"timestamp": "2026-10-05T09:30:12.123456+00:00",
"data": { "userId": "3f1c...", "username": "alice" }
}
| Field | Description |
|---|---|
type |
The event type (see Event types). |
timestamp |
UTC time at which the server emitted the event, in ISO 8601. |
data |
The payload. Keys of live events are camelCase. |
Two frames are sent by the connection itself and do not carry a timestamp:
snapshot: sent once, right after the connection is accepted:{"type": "snapshot", "data": [...]}. For superusersdataholds the last 20 activity log entries, oldest first. For everyone else it is an empty list. Entries in the snapshot keep the database field names in snake_case (user_id,status_code,duration_ms, ...), unlike live events.ping:{"type": "ping"}, sent every 30 seconds. Clients do not need to answer it; see Connection checks.
Note: the snapshot reads the activity log table. If that table does not exist yet (older project, migrations not applied), the snapshot is empty, a warning is logged once per process, and the connection stays open. See Database and migrations.
Authentication¶
A WebSocket URL ends up in proxy and access logs, so it must not contain a long-lived credential. pachifast uses one-time tickets for this.
One-time tickets¶
- The client asks for a ticket with an authenticated
POST:
curl -X POST http://localhost:8000/api/core/events/ticket \
-H "Authorization: Bearer $ACCESS_TOKEN"
{ "ticket": "kP3x...", "expires_in": 30 }
This response is not wrapped in the standard envelope.
- The client opens
GET /api/core/events/ws?ticket=<ticket>within 30 seconds.
A ticket is valid once: ask for a new one at every (re)connection. The user is checked again when the socket opens. Only the SHA-256 digest of a ticket is ever stored, so a copy of the storage cannot be used to open sockets.
Where tickets are stored¶
events_ticket_store |
Storage | Use it when |
|---|---|---|
database (default) |
The ws_ticket table of the auth database (name follows table_prefix). Expired rows are purged whenever a new ticket is issued. |
You run several workers or hosts and have no Redis. Needs the ws_ticket table (see below). |
cache |
The configured cache: process memory by default, Redis when cache_url is set. |
You already run Redis, or a single worker is enough. |
Warning: with
events_ticket_store=cacheand the default in-memory cache, a ticket issued by one worker cannot be redeemed by another. Setcache_urlto a Redis server as soon as you run more than one worker. If the cache is unreachable, issuing or redeeming a ticket fails with a server error.
Projects that use Alembic need a revision for the ws_ticket table; see Database and migrations.
Legacy ?token=¶
For backward compatibility the socket still accepts the access token directly: /ws?token=<access token>. The token is then part of the URL and may be written to logs. Disable this in production:
PACHIFAST_EVENTS_ALLOW_QUERY_TOKEN=false
With the setting off, a connection that only sends ?token= is closed with 4401. If both ticket and token are present, the ticket is used.
Close codes¶
The server always accepts the handshake first and then closes the socket, so that browsers see the close code (closing before accepting would turn into an opaque HTTP 403).
| Code | Meaning | What the client should do |
|---|---|---|
4401 |
Not authorised: missing, invalid, expired or already used ticket; wrong token type (a refresh token is rejected); token issued before the last password change; unknown or deactivated user; ?token= refused by events_allow_query_token=false. It is also sent on an open connection when the user is deleted, deactivated or changes password. |
Do not retry with the same credential. Request a new ticket (or send the user to the login page if the access token cannot be refreshed). |
4403 |
The user's superuser status changed (promoted or demoted). | Reconnect with a new ticket to receive the events of the new role. |
1011 |
Unexpected server error, for example the database is unreachable during authentication. The detail is in the server log. | Retry later, with a back-off. |
Connection checks¶
Every 30 seconds the server re-reads the user, sends the ping, and closes the connection with 4401 or 4403 if something changed. This also catches changes made outside the API, for example with the pachifast users command. The deadline is absolute, so a client that keeps sending messages cannot postpone the check.
- The check ignores token expiry: clients refresh their access token without reopening the socket.
- If the check itself fails (for example the database is down), the connection stays open and the error is logged.
PATCHandDELETE /api/admin/user/{id}close the user's sockets immediately instead of waiting for the next check.
Who receives what¶
No event is broadcast to everybody by default. An event reaches the superusers plus, for events that concern one user, that user. Superuser membership is the one the user had when the connection was opened (a change closes the connection with 4403, as above).
Event types¶
| Type | Payload (data) |
Default audience |
|---|---|---|
login |
{userId, username} |
Superusers |
logout |
{userId, username} |
Superusers |
activity |
{userId, username, method, path, statusCode, description}; only with the activity log enabled |
Superusers |
user_permissions_changed |
{userId} |
Superusers and the affected user |
logout is emitted by POST /api/auth/logout (access token in the header, response 204 without body). JWTs are stateless: the server does not invalidate them, so the client must discard both tokens itself. The endpoint exists to notify the others. It disappears if you disable the auth core router (see Modules).
Public event types¶
To tell every connected user that, for example, somebody has logged in or out, list the types in events_public_types (comma separated in the environment, a list in YAML):
PACHIFAST_EVENTS_PUBLIC_TYPES=login,logout
events_public_types: [login, logout]
Listed types go to all connected clients.
Warning:
activitycontains method and path of every user's requests. Make it public only if that is really what you want.
Activity log¶
The activity log records write requests in the auth database and, optionally, streams them as activity events.
PACHIFAST_ACTIVITY_LOG_ENABLED=true
What is recorded¶
- Requests with method
POST,PUT,PATCHorDELETEwhose path starts with/api.GETrequests are never logged. - Responses with a status below 500. Server errors are not logged.
- The user is read from the bearer token without a database query. Requests without a valid token are logged with no user.
- Each entry stores timestamp, user id, username (denormalised, so the log outlives the user; there is no foreign key), method, path, status code, duration in milliseconds and an optional description.
The description comes from the log_as template of the route, for example:
@router.patch("/{id}", log_as="{username} updated item {id}")
async def update_item(id: int): ...
Path parameters and username can be used as placeholders; unknown placeholders are left untouched. See Routing and responses.
Endpoints¶
Enabling the log mounts two superuser-only routes. Query parameters are camelCase.
| Route | Description |
|---|---|
GET /api/admin/audit/logs |
Paginated list (page, pageSize), newest first by default. |
GET /api/admin/audit/activity |
The most recent entries as a plain list (limit, 1 to 500, default 10). Ignores pagination. |
Filters accepted by both routes:
| Parameter | Description |
|---|---|
orderBy |
Sort field; -timestamp (default) means newest first. |
search |
Case-insensitive text search in username and path. |
userId |
Exact user id (UUID). |
method |
Exact HTTP method. |
statusCode |
Exact status code. |
dateFrom, dateTo |
Inclusive timestamp range. |
pathContains |
Keep paths containing the string. |
excludePaths |
Comma-separated substrings to drop from the result. |
curl -G http://localhost:8000/api/admin/audit/logs \
-H "Authorization: Bearer $ACCESS_TOKEN" \
--data-urlencode "method=DELETE" \
--data-urlencode "pageSize=20"
excludePaths only filters what the endpoints return; the entries are still stored. When the parameter is absent, the activity_log_exclude_paths setting is applied (default refresh-token, which hides token refresh noise). Pass an empty string (excludePaths=) to disable the default.
Let a superuser opt out¶
Every superuser has a user setting, audit.disable_activity_log ("Disable activity log", meant for development and debugging only). When it is true, that user's requests are not logged and no activity event is emitted for them. The choice is remembered for 30 seconds in the cache, so a change can take up to 30 seconds to apply. If the cache is unavailable, the setting is read from the database and a warning is logged.
Untrusted data¶
path, description and username contain what a user typed (for example path parameters), so they are untrusted. Before storing them, and before sending the activity event, the server:
- replaces control characters (C0, C1, line and paragraph separators) with spaces, which prevents log forging;
- truncates
pathanddescriptionto 500 characters andusernameto 150, ending a truncated value with an ellipsis.
Warning: sanitising is not escaping. Clients must always render these fields as plain text, never as HTML.
Browser client¶
The example below requests a ticket, opens the socket and applies the rules above: it never retries on 4401 with a stale credential, and it reconnects with a fresh ticket on 4403.
const API = "https://api.example.com";
/** Ask the API for a one-time ticket (valid 30 seconds, single use). */
async function fetchTicket(accessToken) {
const res = await fetch(`${API}/api/core/events/ticket`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error(`ticket request failed: ${res.status}`);
return (await res.json()).ticket;
}
/**
* Open the event stream and keep it open.
* getAccessToken must return a valid (refreshed if needed) access token.
*/
function connectEvents(getAccessToken, onEvent, onLogout) {
let delay = 1000;
async function open() {
let ticket;
try {
ticket = await fetchTicket(await getAccessToken());
} catch {
return setTimeout(open, delay = Math.min(delay * 2, 30000));
}
const ws = new WebSocket(
`${API.replace(/^http/, "ws")}/api/core/events/ws?ticket=${encodeURIComponent(ticket)}`,
);
ws.onopen = () => { delay = 1000; };
ws.onmessage = (message) => {
const event = JSON.parse(message.data);
if (event.type === "ping") return;
onEvent(event);
};
ws.onclose = (close) => {
if (close.code === 4401) {
// Not authorised: do not loop. Let the app refresh the session or show the login page.
onLogout();
} else if (close.code === 4403) {
// Role changed: reconnect right away with a new ticket.
open();
} else {
// 1011, network error, server restart: retry with exponential back-off.
setTimeout(open, delay = Math.min(delay * 2, 30000));
}
};
}
open();
}
When rendering an activity event, use textContent (or your framework's text binding), never innerHTML:
connectEvents(getToken, (event) => {
if (event.type === "activity") {
const li = document.createElement("li");
li.textContent = `${event.data.username} ${event.data.method} ${event.data.path}`;
list.append(li);
}
}, () => location.assign("/login"));
Settings summary¶
| Setting | Default | Description |
|---|---|---|
events_broadcast_enabled |
false |
Mounts the WebSocket and ticket routes. |
events_ticket_store |
database |
database or cache. |
events_allow_query_token |
false |
Accept the legacy ?token= (the token would end up in access logs). Off by default. |
events_public_types |
empty | Event types sent to every connected client. |
activity_log_enabled |
false |
Records write requests and mounts the audit routes. |
activity_log_exclude_paths |
refresh-token |
Default excludePaths of the audit routes. |
All options are listed in the settings reference.
See also¶
- Authentication: access tokens, refresh tokens and logout.
- Cache: the store behind tickets and the activity-log opt-out.
- Database and migrations: the
activity_logandws_tickettables. - Operations: production security checklist.