Skip to content

Rotur notifications

OriginChats has two notification paths:

  1. A connected client can create a foreground browser notification when it receives a relevant message_new event.
  2. Rotur can deliver Web Push to a registered service worker while the client is closed or suspended.

These paths complement each other. WebSocket events do not wake a closed browser, and Web Push should not duplicate an alert already handled by a focused tab.

The official client implementation is split across src/net/commands/message/new.ts, src/lib/services/push-manager.ts, src/net/api/notify.ts, and src/sw.ts in originchats-client.

originchats-osl can send the second path directly through Rotur for messages created with the WebSocket message_new command. It schedules delivery only after the message has been persisted, so a failed write never produces a push for a message that does not exist. Push delivery runs outside the WebSocket request and a Rotur outage does not turn a successful message_new into an error.

Handle notification policy after applying the incoming message to client state. Do not notify when any of these conditions apply:

  • the message is authored by the current user;
  • the sender is blocked;
  • the message or channel is muted;
  • the relevant channel/thread is actively visible in a focused document;
  • an ephemeral message is not meant to create durable unread state.

The official client defaults to mention/reply notifications, supports per-server and per-channel all, mentions, and none levels, and always treats a direct message as notification-worthy when it is not muted. It marks unread state before displaying the alert.

function showForegroundNotification(
title: string,
body: string,
serverUrl: string,
channel: string,
) {
if (Notification.permission !== 'granted') return;
const notification = new Notification(title, {
body,
tag: `${serverUrl}:${channel}`,
});
notification.onclick = () => {
window.focus();
notification.close();
openChannel(serverUrl, channel);
};
}

Use a stable tag per server and channel. A single application-wide tag causes unrelated notifications to replace one another.

Request notification permission only in response to a deliberate user action. A denied permission cannot be fixed by retrying; direct the user to their browser or operating-system settings.

const permission = await Notification.requestPermission();
if (permission !== 'granted') return;

Feature-detect all required browser APIs before offering background push:

const supported =
'serviceWorker' in navigator
&& 'PushManager' in window
&& 'Notification' in window;

Web Push requires a secure context in production. Service workers and push subscriptions are unavailable in some embedded browsers and privacy modes, so foreground WebSocket notifications must remain a valid fallback.

Use the constant source name originChats consistently. Rotur scopes endpoint registration and allowed senders by this source.

import { Rotur } from 'rotur-sdk';
const SOURCE = 'originChats';
const rotur = new Rotur({ token: roturToken });
const { public_key: vapidPublicKey } = await rotur.push.vapidKeys();
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64urlToBytes(vapidPublicKey),
});
const json = subscription.toJSON();
await rotur.push.register(
subscription.endpoint,
json.keys!.p256dh!,
json.keys!.auth!,
SOURCE,
await deviceFingerprint(),
);

The fingerprint identifies this client installation to Rotur; it is not a cryptographic device identity. Keep it stable enough to update a changed endpoint, but do not build it from invasive or unnecessary fingerprinting inputs.

The official client checks registration first with rotur.push.check(SOURCE, fingerprint). It registers when missing and re-registers when the stored endpoint differs from the browser’s current subscription.

The equivalent Rotur v2 calls use the user’s bearer token except for the public VAPID key:

Method Path Authentication Purpose
GET /notify/vapid None Get the application server public key
GET /notify/check?source=…&fingerprint=… Bearer token Check this device registration
POST /notify/register Bearer token Create or update an endpoint

All paths are beneath https://api.rotur.dev/v2.

{
"endpoint": "https://push-service.example/subscription-id",
"p256dh": "<base64url key>",
"auth": "<base64url secret>",
"source": "originChats",
"fingerprint": "<stable installation fingerprint>"
}

Send the registration body as JSON with Authorization: Bearer <Rotur token>. Never send the Rotur token, subscription keys, or endpoint over the OriginChats WebSocket.

Rotur requires the receiving user to allow a sender for a source. The official client uses the server.owner.name supplied by the OriginChats handshake:

await rotur.push.allowSender(serverOwnerUsername, 'originChats');

Removing access uses:

await rotur.push.removeSender(serverOwnerUsername, 'originChats');

Authorization is per Rotur username and source, not per OriginChats server URL. If one owner operates several servers, enabling or disabling that owner affects all of them. Clients must show this scope clearly rather than presenting it as an isolated per-server permission.

Treat the handshake owner as an asserted server identity. A client concerned about impersonation should apply the same server identity and validator-key checks used during connection setup before inviting the user to authorize that owner.

Sending belongs in trusted infrastructure, not in a browser client. A sender needs a Rotur token with notifications:send; exposing that credential would let anyone impersonate the server’s notification sender.

With the SDK:

const sender = new Rotur({ token: serverOwnerToken });
await sender.push.sendMany(recipientUsernames, 'originChats', {
title: `${authorUsername} in #${channel}`,
body: truncatedMessagePreview,
data: {
source: publicServerUrl,
channelName: channel,
threadId,
messageId,
},
});

For one recipient, use rotur.push.send(username, source, options). Before batching, rotur.push.notifiableUsers('originChats') can return users who registered this source and allowed the authenticated sender. Intersect that set with actual message recipients; never broadcast to every notifiable user.

Without the SDK, send the same fields to Rotur v2:

{
"source": "originChats",
"title": "alice in #general",
"body": "A short message preview",
"data": {
"source": "https://chat.example.com",
"channelName": "general",
"threadId": null,
"messageId": "550e8400-e29b-41d4-a716-446655440000"
},
"users": [
"sophie"
]
}

Use POST https://api.rotur.dev/v2/notify/ with the sender’s bearer token. For one username, use POST /notify/{username} and omit users.

Only queue a push after the message has been persisted successfully. Recipient selection must reuse server authorization rules:

  • exclude the author;
  • include only users allowed to view the channel and thread;
  • honor bans and membership state;
  • select mention, role-mention, reply, or direct-message targets according to supported policy;
  • avoid including sensitive message content when the recipient should receive only a generic alert.

The OSL server does not store each client’s local notification-level override. Its server-wide mentions mode is therefore the conservative default; all is an operator choice and sends to every eligible server member. A receiving user can disable background delivery by removing the server owner’s Rotur sender authorization.

Background delivery is off by default. Enable it in the server database’s config.json:

{
"notifications": {
"enabled": true,
"mode": "mentions",
"source": "originChats",
"include_content": true,
"max_preview_length": 160
}
}

Provide the sending account’s Rotur bearer token to the server process:

Terminal window
export ROTUR_NOTIFICATION_TOKEN='replace-with-server-owner-token'
./originchats-osl use /absolute/path/to/server-db

The token must have notifications:send, and its Rotur username must be the sender the client authorizes. ROTUR_TOKEN remains a compatibility fallback when ROTUR_NOTIFICATION_TOKEN is absent, but the dedicated variable is preferred. Never put either token in config.json.

Setting Behavior
enabled Enables server-side Rotur delivery.
mode: "mentions" Sends to direct user mentions, permitted role mentions, and reply targets whose reply ping was not disabled.
mode: "all" Sends to every eligible server user.
source Rotur registration source. Keep this as originChats for the official client.
include_content Includes a shortened message body when true; otherwise uses a generic preview.
max_preview_length Maximum number of message-content characters placed in the push body.

Both modes exclude the author, banned users, unknown users, and anyone who cannot view the channel. Recipient usernames are deduplicated before the server uses Rotur’s batch endpoint. Transient network errors, 429, and 5xx responses receive up to three total attempts; other 4xx responses are not retried. If notifications are enabled without a token, startup logs a warning and message delivery continues normally.

A service worker should parse JSON defensively, fall back to text, suppress duplicates when an appropriate window is already active, and keep navigation data separate from presentation text.

self.addEventListener('push', (event) => {
if (!event.data) return;
event.waitUntil((async () => {
const payload = event.data.json();
const windows = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
});
if (windows.some((client) => client.focused)) return;
const data = payload.data ?? {};
await self.registration.showNotification(payload.title || 'OriginChats', {
body: payload.body || '',
icon: '/originchats-logo.jpg',
badge: '/originchats-logo.jpg',
tag: `${data.source ?? 'originchats'}:${data.channelName ?? 'inbox'}`,
data,
renotify: true,
});
})());
});

On notification click, prefer an already-open window for the same server, send it a navigation message, and focus it. Only call openWindow() when no suitable client exists. Validate data.source before turning it into a route; do not navigate to an arbitrary external URL from push data.

Push subscriptions can rotate without user action. Proper handling includes:

  • comparing the subscription’s current endpoint with Rotur’s recorded endpoint;
  • recreating the subscription when Rotur’s VAPID public key changes;
  • responding to pushsubscriptionchange and re-registering through a live page;
  • retrying transient network and 5xx failures with bounded exponential backoff;
  • deferring registration work while offline and resuming on the online event;
  • clearing cached registration state when the Rotur token changes or the user logs out;
  • removing event listeners and polling intervals when the application is torn down.

Do not repeatedly request browser permission, create parallel subscriptions, or retry 401/403 responses without first fixing authentication or authorization.

Push payloads pass through browser push infrastructure and appear on lock screens. Keep bodies short, avoid secrets and attachment URLs, and offer a generic-preview mode. Put only routing identifiers in data; fetch authoritative message content after the user opens the client.

Use these states separately in the UI:

State Meaning
Unsupported Required browser APIs are unavailable
Permission required Browser permission has not been requested
Blocked Browser or OS permission is denied
Unregistered Permission exists but Rotur has no current endpoint
Registered Endpoint is current, but no server owner may be allowed
Enabled for owner Registration exists and that Rotur sender is allowed

This distinction makes failures diagnosable and prevents a checked toggle from implying delivery is guaranteed.