Overlays
Overview
The Overlays module provides a custom streaming overlay editor where users can create, configure, and manage overlays with layered widgets. Each overlay has a unique key used for popout access in browser sources. Overlays support configurable dimensions, background settings, and multiple layer types (alerts, chat, music, custom HTML/CSS). Layers are ordered by sort_order and can be individually toggled visible/hidden.
Architecture
Backend
- GraphQL (
apps/api/src/graphql/overlays.rs) -- Full CRUD for overlays and layers. Queries for listing/fetching overlays and their layers. Mutations for create, update, delete overlays and bulk-replace layers. - Database (
apps/api/src/db/overlays.rs) -- PostgreSQL operations for overlays and overlay_layers tables. Includesgenerate_overlay_key()which creates a 12-character URL-safe random key. - Browser Source Access -- Overlays render at
/overlay/[key]?token=…. Thetokenis the overlay's own Overlay Access Token (lm_overlay_*) or a temporary Shared Overlay Token (lm_share_*) — not a popout token. The page returns403 — Access token requiredwhen the query parameter is absent.
Frontend
- Overlay editor UI in the Next.js web app under
/dashboard/overlays. - The browser-source renderer (
apps/web/src/app/(overlay)/overlay/[key]/) loads via the unique overlay key and passes the access token straight into the WebSocket URL. - Layer configuration per layer type (alert config, chat style, music widget config, custom HTML/CSS/JS).
- OBS connection parameters can be supplied to a shared link through
obs_host/obs_port/obs_passwordquery parameters; for a permanent token they come from the overlay's stored bootstrap configuration instead.
Token Types on Overlay Surfaces
Three different tokens appear around overlays; they are not interchangeable:
| Token | Prefix | Used for |
|---|---|---|
| Overlay Access Token | lm_overlay_* | The overlay's own permanent Browser Source URL (/overlay/[key]) |
| Shared Overlay Token | lm_share_* | Time-limited shared access to one overlay |
| Popout Token | lm_pop_* | Dashboard popout views (/popout/[view]) — carries RBAC permissions, unlike the two above |
Dashboard popout views and the API hooks behind them authenticate with a popout token rather than a session cookie, so every hook they use must accept popout-token auth as well as cookie auth. The overlay browser source is the opposite case: its token carries no RBAC permissions and can reach only the overlay's WebSocket channel.
The permanent Overlay Access Token (and the equivalent Widget Access Token) is
stored AES-256-GCM-encrypted at rest, the same pattern as app_credentials:
auth resolution keys on the token hash, and the encrypted copy exists only so
the dashboard can decrypt it on demand to rebuild the Browser Source URL — the
cleartext never sits in the database. Resolution is additionally gated on
revoked_at / expires_at on every request, like the popout and shared-overlay
tokens. Rotating a token (copyOverlayUrl) is a de-facto revocation of the old
one.
API
GraphQL Queries
Every overlay query and mutation is gated by a feature flag and an RBAC
permission, composed as FeatureGuard::new(<flag>).and(PermissionGuard::new(<permission>))
on GraphQL and as auth.require_permission(…) + require_feature(…) on REST.
The flag differs by area: core CRUD uses feature:overlays, folders use
feature:overlay_folders, per-overlay access control uses feature:overlay_access,
and shared links use feature:overlay_sharing.
| Query | Feature | Permission | Description |
|---|---|---|---|
overlays | feature:overlays | overlays:read | List all overlays for the account |
overlay(id: UUID) | feature:overlays | overlays:read | Get a single overlay by ID |
overlayLayers(overlayId: UUID) | feature:overlays | overlays:read | Get all layers for an overlay, ordered by sortOrder |
overlayAccessUrl(overlayId: UUID) | feature:overlays | overlays:read + Editor access | Return the overlay's Browser Source URL from the stored token, without rotating it. Handing out the cleartext lm_overlay_* token requires Editor-level access to the overlay (mirrors copyOverlayUrl), not merely overlays:read — a read-only Viewer cannot fetch it. null when the overlay has no retrievable token. |
overlayFolders | feature:overlay_folders | overlays:read | List the account's overlay folders |
overlayAccess(overlayId) | feature:overlay_access | overlays:access-read | List per-overlay access entries |
overlayAccessCandidates(overlayId) | feature:overlay_access | overlays:access-read | List members who can be granted access |
overlaySharedLinks(overlayId) | feature:overlay_sharing | overlays:edit | List the overlay's temporary shared links |
GraphQL Mutations
| Mutation | Feature | Permission | Description |
|---|---|---|---|
createOverlay(input: CreateOverlayInput) | feature:overlays | overlays:create | Create a new overlay (defaults: 1920x1080, "transparent" background, "Main Overlay" name) |
updateOverlay(input: UpdateOverlayInput) | feature:overlays | overlays:edit | Update overlay name, dimensions, or background |
updateOverlayLayers(overlayId: UUID, layers: JSON) | feature:overlays | overlays:edit | Bulk-replace all layers for an overlay. Each layer has: type, name, config (JSONB), visible, sort_order. Optionally include id to preserve identity. |
deleteOverlay(id: UUID) | feature:overlays | overlays:delete | Delete an overlay and all its layers |
copyOverlayUrl(overlayId: UUID) | feature:overlays | overlays:edit | Rotate the overlay access token and return the fresh Browser Source URL. The previous URL stops working immediately. |
revokeOverlayToken(overlayId: UUID) | feature:overlays | overlays:edit | Destroy the overlay access token without re-issuing one |
moveOverlay(overlayId: UUID, folderId: UUID) | feature:overlay_folders | overlays:edit | Move an overlay into a folder; null moves it back to root |
REST Endpoints
Bodies are snake_case and mirror the GraphQL inputs.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/overlays | overlays:read | List overlays for the account |
POST | /v1/overlays | overlays:create | Create an overlay |
GET | /v1/overlays/{id} | overlays:read | Get an overlay with its layers |
PATCH | /v1/overlays/{id} | overlays:edit | Update name, dimensions, background, or layers |
DELETE | /v1/overlays/{id} | overlays:delete | Delete an overlay and its layers |
POST | /v1/overlays/{id}/copy-url | overlays:edit | Rotate the access token and return the fresh Browser Source URL |
POST | /v1/overlays/{id}/revoke-token | overlays:edit | Revoke the access token without re-issuing |
POST | /v1/overlays/{id}/move | overlays:edit | Move the overlay into a folder (feature:overlay_folders) |
The same feature flags apply on REST. Note that apps/api/openapi.json currently
registers only the core five plus move — copy-url, revoke-token, the access
endpoints and the shared-link endpoints are served but are not listed in the
generated OpenAPI document.
There is no REST route for reading an overlay's layers on their own or for the
stored Browser Source URL; use the overlayLayers and overlayAccessUrl
GraphQL queries. The web dashboard reaches both through its Next.js proxy routes
(/api/overlays/[id]/layers, /api/overlays/[id]/url), which execute those
GraphQL operations server-side.
Input Types
CreateOverlayInput:
| Field | Type | Default | Description |
|---|---|---|---|
name | String? | "Main Overlay" | Overlay name |
width | i32? | 1920 | Canvas width in pixels |
height | i32? | 1080 | Canvas height in pixels |
background | String? | "transparent" | Background color/value |
UpdateOverlayInput:
| Field | Type | Description |
|---|---|---|
id | UUID | Overlay ID (required) |
name | String? | New name |
width | i32? | New width |
height | i32? | New height |
background | String? | New background |
Permissions
| Permission | Description |
|---|---|
overlays:read | View overlay configuration and layers |
overlays:create | Create new overlays |
overlays:edit | Edit overlay settings and layers |
overlays:delete | Delete overlays |
Database
| Table | Database | Description |
|---|---|---|
overlays | PostgreSQL | id, account_id, name, key (unique 12-char URL-safe string), width, height, background, created_at, updated_at |
overlay_layers | PostgreSQL | id, overlay_id (FK), type (layer type string), name, config (JSONB), visible (bool), sort_order (int), created_at, updated_at |
overlay_access_tokens | PostgreSQL | id, overlay_id (FK, unique), account_id (FK), token_hash (SHA-256, unique), created_at. One token per overlay; cascade-deleted with the overlay. |
Overlay Key Generation
The generate_overlay_key() function creates a 12-character random string using characters A-Z, a-z, 0-9, -, _. This key is used in the popout URL: /overlay/[key]?token=xxx.
Access Tokens
Each overlay is protected by a dedicated access token (lm_overlay_* prefix). The token is a 75-character hex-encoded string stored as a SHA-256 hash in the overlay_access_tokens table. Access requires both the overlay key (in the URL path) and a valid token — a two-secret-layer model that prevents unauthorized subscribes even if one value leaks.
Security Model
- Anonymous WebSocket subscribes to
overlay:{key}channels are blocked.channel_gate_for("overlay")returnsChannelGate::OverlayToken, which requires anAuthContext::OverlayTokenwhoseoverlay_idmatches the overlay resolved from the channel key — so one overlay's token cannot subscribe to another's channel. - The server re-validates the token against the database every 30 seconds, throttled inside the 5-second heartbeat tick. Revoked or rotated tokens cause an immediate disconnect with
TOKEN_REVOKED. - The re-validation query has a 500 ms timeout and fails open on timeout: a slow database keeps existing browser sources connected rather than dropping every overlay at once. Revocation therefore takes effect on the next successful check.
- The token carries no RBAC permissions and cannot access REST or GraphQL endpoints.
- The
overlaychannel type has no entry inchannel_feature_for, so the WebSocket layer applies no plan-feature gate to it; the plan check happens when the overlay and its token are managed through GraphQL/REST.
Token Lifecycle
| Event | Behavior |
|---|---|
Overlay creation (createOverlay) | A token is auto-issued and returned in the response |
| Copy URL (dashboard button) | The existing token is revoked and a fresh token is issued (auto-rotation). The old URL stops working immediately. |
Revoke (revokeOverlayToken) | The token is destroyed without re-issue. Use for compromise emergencies — the overlay becomes inaccessible until a new URL is copied. |
| Overlay deletion | The token is cascade-deleted with the overlay row |
Transport
The token is passed via ?token=lm_overlay_… query parameter (primary, for browser sources) or Authorization: Bearer lm_overlay_… header.
OBS Setup
- Open the overlay editor in the Lumio dashboard.
- Click Copy URL — this generates a fresh token and copies the full URL to the clipboard.
- In OBS, add a Browser Source and paste the URL.
- The browser source connects automatically; no further configuration is needed.
Overlays Without a Token
An overlay can exist without an access token — for example after revokeOverlayToken, or for rows that predate the token requirement. overlayAccessUrl returns null for such an overlay and its URL grants no WebSocket access. Clicking Copy URL (copyOverlayUrl) issues a token and produces a working URL.
Data Flow
- User creates an overlay via the editor. A unique key and access token are generated.
- User adds layers (alert, chat, music, custom) and configures each one.
- Layers are saved via
updateOverlayLayersmutation (bulk replace). - The overlay is accessed in OBS via its URL containing the overlay key and access token.
- The popout page loads the overlay and all its layers, connecting to WebSocket for real-time updates using the access token for authentication.
Folders
Overlays can be organized into flat folders (one level, no nesting). Each account has unique folder names.
Creating Folders
- GraphQL:
createOverlayFolder(name: String!)— requiresoverlays:createpermission andfeature:overlay_foldersfeature flag - REST:
POST /v1/overlay-folders— body:{ "name": "..." }
Moving Overlays
- GraphQL:
moveOverlay(overlayId: UUID!, folderId: UUID)— passnullforfolderIdto move back to root - REST:
POST /v1/overlays/{id}/move— body:{ "folder_id": "..." | null }
Requires overlays:edit permission, feature:overlay_folders feature flag, and per-overlay editor access.
Folder Management
| Operation | GraphQL | REST | Permission |
|---|---|---|---|
| List | overlayFolders | GET /v1/overlay-folders | overlays:read |
| Create | createOverlayFolder | POST /v1/overlay-folders | overlays:create |
| Rename | renameOverlayFolder | PATCH /v1/overlay-folders/{id} | overlays:edit |
| Delete | deleteOverlayFolder | DELETE /v1/overlay-folders/{id} | overlays:delete |
All folder endpoints require feature:overlay_folders to be enabled for the account's plan.
Access Control
Per-overlay member access control refines the account-level RBAC permissions. Users with overlays:access-read (Owner and Administrator roles by default) bypass all per-overlay restrictions.
Access Levels
| Level | Can View | Can Edit |
|---|---|---|
| Viewer | Yes | No |
| Editor | Yes | Yes |
| No Access | No | No |
Resolution Logic
db::overlay_access::can_access_overlay resolves in this order:
- Verify the overlay actually belongs to the caller's account. A mismatch is denied outright — this runs first, so the
overlays:access-readbypass can never reach another account's overlay. - If the caller has
overlays:access-read→ full access (bypass) - If the caller lacks
overlays:read→ no access - If the auth context has no
user_id→ no access (a per-overlay entry is keyed on a user) - If no per-overlay entry exists → default Viewer access
- If entry has role
none→ blocked - If entry has role
editor→ full access - If entry has role
viewer→ view only
Steps 5–8 are shared with other per-resource access checks via db::resource_access::resolve_access.
Endpoints
| Operation | GraphQL | REST | Permission |
|---|---|---|---|
| List access | overlayAccess(overlayId) | GET /v1/overlays/{id}/access | overlays:access-read |
| Set access | setOverlayAccess(overlayId, userId, role) | PUT /v1/overlays/{id}/access/{userId} | overlays:access-grant |
| Remove access | removeOverlayAccess(overlayId, userId) | DELETE /v1/overlays/{id}/access/{userId} | overlays:access-revoke |
| List candidates | overlayAccessCandidates(overlayId) | GET /v1/overlays/{id}/access/candidates | overlays:access-read |
All access control endpoints require feature:overlay_access to be enabled.
Shared Links
Temporary shared overlay links allow time-limited access to a specific overlay without requiring account membership. Links use the lm_share_ token type (7th authentication type).
Token Format
- Prefix:
lm_share_(9 characters) - Body: 32 random bytes, hex-encoded (64 characters)
- Total length: 73 characters
- Storage: SHA-256 hash in database
Allowed Durations
Links can be created with one of five fixed durations:
| Duration | Seconds |
|---|---|
| 1 hour | 3600 |
| 3 hours | 10800 |
| 6 hours | 21600 |
| 12 hours | 43200 |
| 24 hours | 86400 |
Endpoints
| Operation | GraphQL | REST | Permission |
|---|---|---|---|
| List | overlaySharedLinks(overlayId) | GET /v1/overlays/{id}/shared-links | overlays:edit + editor |
| Create | createOverlaySharedLink(overlayId, durationSecs) | POST /v1/overlays/{id}/shared-links | overlays:edit + editor |
| Extend | extendOverlaySharedLink(linkId, durationSecs) | PATCH /v1/overlay-shared-links/{id} | overlays:edit + editor |
| Revoke | revokeOverlaySharedLink(linkId) | DELETE /v1/overlay-shared-links/{id} | overlays:edit + editor |
All shared link endpoints require feature:overlay_sharing to be enabled.
WebSocket Access
Shared links authenticate via the ?token=lm_share_... query parameter on WebSocket connections. The token grants subscription to the overlay's WebSocket channel (overlay:{key}). The server performs periodic heartbeat checks:
- In-memory expiry: checked every heartbeat tick (5s) — disconnects with
TOKEN_EXPIRED - DB revocation: checked every 30 seconds — disconnects with
TOKEN_REVOKED
Key Files
| Path | Description |
|---|---|
apps/api/src/graphql/overlays.rs | GraphQL queries and mutations |
apps/api/src/routes/overlays.rs | REST handlers (/v1/overlays, /v1/overlay-shared-links) |
apps/api/src/routes/overlay_folders.rs | REST handlers for /v1/overlay-folders |
apps/api/src/db/overlays.rs | Database operations, key generation, access-token issuance |
apps/api/src/db/overlay_shared_links.rs | Shared-link rows and ALLOWED_DURATIONS |
crates/lo-auth/src/overlay_token.rs | lm_overlay_* generation and prefix validation |
crates/lo-auth/src/shared_token.rs | lm_share_* generation and prefix validation |
crates/lo-websocket/src/session.rs | Heartbeat token re-validation (TOKEN_REVOKED / TOKEN_EXPIRED) |
apps/web/src/app/(overlay)/overlay/[key]/ | Browser-source renderer |