Skip to main content

OBS Integration

Overview​

Lumio integrates with OBS Studio for remote control (scenes, stream/recording start-stop, status monitoring). Three distinct paths exist, and they do not share a transport:

PathWho connects to obs-websocketState
/dashboard/obs and /popout/obsthe API server's per-account OBS worker (ZAF-1122); the pages call the /api/obs-remote/* proxy routes, which resolve the GraphQL obsRemoteStatus query + obs* mutations (never REST directly)Re-enabled: renders the connection badge, scene grid and stream/recording toggles, all gated on available - falls back to an honest "coming soon" state when no worker is publishing a snapshot. A command to a disconnected worker returns 409 Conflict "OBS remote control is not connected". The worker transport is wired in ZAF-1122
Overlay pages (/overlay/[key])the browser, from inside OBS, using ObsWebSocketClient from @lumio/obsWorking. Credentials arrive in the WebSocket bootstrap payload; obs:action events pushed by automations are executed locally
testObsConnection / POST /v1/integrations/obs/testthe API server, using lo_obs_remote::ObsRemoteClientWorking, but only a connect-and-disconnect probe with a 10-second timeout
PieceFlagWhat it is
OBS Remote feature umbrellafeature:obs_remoteGates every OBS query, mutation and REST route, plus the pages /dashboard/obs and /popout/obs. This is the master switch for the whole feature.
WebSocket transportintegration:obs_websocketThe stored obs-websocket connection details (port + password + optional remote host). Configured by the streamer in the Services area of the settings window's Connections group (/dashboard/settings/channel/connections/services). Also gates whether overlay bootstrap payloads carry OBS credentials.
Browser Source overlay capabilitywidget:obs_browser_sourceReserved flag for overlay widgets that would consume the window.obsstudio JS API through useObsBrowserSource(). Seeded and toggleable, but no code path reads it today. Classified under widget:* because it is an overlay-side capability.

Gating semantics per flag:

  • feature:obs_remote off → /dashboard/obs (via FeatureRouteGate on its layout.tsx) and /popout/obs (via an SSR feature check) render <FeatureDisabledPage>, and every OBS query, mutation and REST route rejects the request.
  • integration:obs_websocket off → write endpoints for the WebSocket integration (PUT /v1/integrations/obs, DELETE /v1/integrations/obs, GraphQL saveObsConfig, deleteObsConfig) reject the request, and overlay bootstrap payloads omit the obs credentials block. Read endpoints still return existing config for visibility. The user can't add/save/delete the WebSocket connection in the Services area.
  • widget:obs_browser_source off → no observable effect. No widget renderer, settings panel or editor-picker entry consumes this flag, and ObsBrowserSourceProvider mounts unconditionally at the overlay root (it gates internally on window.obsstudio availability). A future overlay widget that reacts to OBS state should check useFeature("widget:obs_browser_source") itself.

Admins toggle all three flags on /admin/feature-flags. Per-plan or per-account overrides work the same as any other feature flag - see the admin feature-flags doc.

The stored obs-websocket connection​

integration:obs_websocket covers the connection details Lumio stores for an account. Lumio does not hold a long-lived socket to OBS on the server; it stores the credentials and hands them to whichever surface needs them.

  • Requires: the user installs OBS, enables the built-in obs-websocket server (Tools → obs-websocket Settings), notes the port and optional password.
  • User configuration: Settings → Connections → Services → OBS → Set up (or Edit). Fields: port (default 4455), password (optional), remote_enabled toggle, remote_host (only when remote_enabled is true).
  • Password encryption: AES-256-GCM via crypto::encrypt_versioned() (writes are v2.-tagged; see OAuth credentials architecture) with a key derived from auth.token_encryption_key. Stored in the JSONB config column of integration_configs.
  • Save semantics: PUT /v1/integrations/obs / saveObsConfig writes the whole config object as an upsert. Submitting an empty or omitted password clears the stored password rather than leaving the previous one in place.
  • Remote-host validation: remote_host must be a public IP address or a dotted DNS name. Both OBS write surfaces (PUT /v1/integrations/obs, saveObsConfig) refuse with the same 400 message: an IP literal in loopback, private, link-local, CGNAT, multicast, reserved or documentation space (IPv4 and IPv6, including ULA fc00::/7, fe80::/10 and IPv4-mapped/NAT64 forms) (OBS host must not be a private, loopback or link-local address); localhost or a name ending in .localhost, .local, .internal, .lan, .home.arpa, .intranet or .corp (OBS host must not be a local/internal address); anything else that is not a dotted hostname, such as a single-label name, a numeric IPv4 shorthand (127.1, 2130706433) or a value carrying /, :, @, ? or # (OBS host must be a public IP address or DNS name); and remote_enabled: true without a host (OBS host is required when remote control is enabled). This is an SSRF guard. The generic updateIntegration refuses any OBS config write with BAD_REQUEST (The OBS config can only be changed in the OBS settings), so these surfaces are the only write path.
  • Connect-time check: the OBS Remote worker and both test-connection surfaces check the stored host again before connecting, so a row written before the guard existed is skipped, not followed. They resolve the name, refuse it if any resolved address is non-public (OBS host resolves to a private, loopback or link-local address), and connect to the checked IP rather than the name, so a DNS answer that changes between check and connect cannot redirect the socket. The worker resolves again on every reconnect.
  • Transport: TCP WebSocket to ws://{resolved-ip}:{port}. There is no localhost default: a remote-enabled row without a host is skipped by the worker, and a test connection without a host fails with OBS host is required when remote control is enabled. Remote mode requires a publicly reachable host (and port-forwarding in the user's router).

Browser Source overlay capability​

widget:obs_browser_source is the reserved flag for overlay widgets that integrate with the window.obsstudio JS API. OBS injects this API into any page loaded as a Browser Source inside OBS. The provider and hook described below exist and are mounted; no widget consumes them yet, and nothing reads the flag.

ObsBrowserSourceProvider​

The ObsBrowserSourceProvider is mounted unconditionally at the overlay root (OverlayClient for the live overlay, OverlayPreview for editor preview). It:

  1. Detects window.obsstudio presence at mount time (SSR-safe: no-op on the server).
  2. Fetches initial state in parallel: plugin version, control level, stream/recording/replay-buffer/virtualcam status, current scene, scene list, transition list, current transition.
  3. Subscribes to every event in the OBSEventType union except obsReplaybufferSaved (which needs no state change) - scene and scene-list changes, transition and transition-list changes, the four streaming and six recording transitions, the four replay-buffer transitions, virtualcam start/stop, source visibility/active changes, and obsExit (which resets the provider to a disconnected state).
  4. Runs status polling every 2 000 ms as a drift-correction backup.
  5. Cleans up all listeners and polling on unmount.

When OBS is not detected, the Provider supplies no-op defaults - all methods are safe to call unconditionally.

useObsBrowserSource()​

Consumer widgets import useObsBrowserSource from @/contexts/obs-browser-source-context to read live OBS state:

import { useObsBrowserSource } from "@/contexts/obs-browser-source-context";

function MyWidget() {
const { available, currentScene, status, setScene } = useObsBrowserSource();
// ...
}

Available fields:

FieldTypeDescription
availablebooleanTrue when window.obsstudio was detected
pluginVersionstring | nullobs-browser plugin version
controlLevelOBSControlLevelCurrent control permissions (NONE/READ_OBS/READ_USER/BASIC/ADVANCED/ALL)
statusOBSStatus | nullstreaming / recording / recordingPaused / replayBuffer / virtualcam booleans
currentScenestring | nullActive scene name
currentSceneInfoOBSScene | nullActive scene object including canvas width and height (useful when canvas dimensions are needed)
scenesstring[]All scene names
transitionsstring[]All available transition names
currentTransitionstring | nullActive transition name
canRead()() => booleancontrolLevel >= READ_OBS (1)
canReadUser()() => booleancontrolLevel >= READ_USER (2)
canControl()() => booleancontrolLevel >= BASIC (3)
canModify()() => booleancontrolLevel >= ADVANCED (4)
canFullControl()() => booleancontrolLevel == ALL (5)

Control methods (setScene, setTransition, startStreaming, stopStreaming, startRecording, stopRecording, pauseRecording, unpauseRecording, startReplayBuffer, stopReplayBuffer, saveReplayBuffer, startVirtualcam, stopVirtualcam) are no-ops when OBS is not available. Capability helpers canReadUser() and canControl() are also available (see table above).

Outbound event relay (not implemented)​

Forwarding OBS Browser Source events from the overlay to the Lumio backend is not implemented. The call site is marked with a TODO in apps/web/src/contexts/obs-browser-source-context.tsx, and a matching TODO block in apps/web/src/hooks/use-obs-websocket.ts sketches the intended shape. The reverse direction - backend → overlay → OBS - does work; see below.

Overlay control relay​

This is the OBS control path that actually reaches OBS today, and it runs entirely through the overlay:

  1. The account has an OBS integration config and at least one overlay layer of type obs_browser_source (or obs).
  2. When that overlay's WebSocket session starts, OverlayBootstrapProvider (apps/api/src/services/bootstrap_provider.rs) decrypts the stored password and adds an obs block (port, password, remote_enabled, remote_host) to the bootstrap payload - but only when integration:obs_websocket is enabled for the account and the overlay is authenticated with a permanent token. Shared overlay tokens never receive credentials; a shared link may instead carry them explicitly as URL params.
  3. useObsLocalConnect (apps/web/src/hooks/use-obs-local-connect.ts) opens an obs-websocket v5 connection from the browser to the local OBS instance using those credentials, and reconnects automatically if OBS restarts.
  4. An automation's OBS-control action publishes to the Redis channel lumio:obs:action:{account_id} (apps/api/src/dispatch.rs), which reaches the overlay as an obs:action WebSocket event.
  5. handleObsRelayAction (apps/web/src/hooks/use-obs-websocket.ts) dispatches it to the local client. Supported actions: set_current_scene, set_current_transition, set_muted, set_volume, set_filter_enabled, start_stream, stop_stream, start_recording, stop_recording. An unknown action is logged and ignored.

Key files​

PathDescription
apps/web/src/contexts/obs-browser-source-context.tsxProvider + context + useObsBrowserSource hook
apps/web/src/app/(overlay)/overlay/[key]/overlay-client.tsxMounts ObsBrowserSourceProvider, calls useObsLocalConnect, routes obs:action events
apps/web/src/components/overlay/overlay-preview.tsxMounts ObsBrowserSourceProvider for the editor preview
apps/web/src/hooks/use-obs-local-connect.tsBrowser-side obs-websocket connection from bootstrap credentials
shared/obs/src/client.tsObsBrowserSource class (typed window.obsstudio wrapper)
shared/obs/src/types.tsOBSStatus, OBSScene, OBSTransition, OBSEventType, OBSControlLevel

There is no ObsBrowserSourceWidget renderer and no ObsBrowserSourceSettings panel - the overlay editor has no picker entry for this capability, so an obs_browser_source layer cannot currently be created from the editor UI even though the bootstrap provider recognises the layer type.

Admin configuration​

Toggle transports globally​

/admin/feature-flags - flip any of:

  • feature:obs_remote - kills the entire OBS remote feature for all accounts
  • integration:obs_websocket - kills the stored WebSocket connection globally (users can't add or edit the OBS integration, and overlays stop receiving credentials)
  • widget:obs_browser_source - reserved; toggling it has no runtime effect today

Per-plan / per-account overrides​

  • Plan overrides: use the plan's feature-settings in /admin/plans/{slug} to disable any of the three flags for specific tiers.
  • Account overrides: use /admin/accounts/{id}/features to set a per-account override.

See the admin feature-flags doc for the resolution chain.

User configuration​

Dashboard UI​

  • /dashboard/obs - the OBS Remote page. Gated on feature:obs_remote via FeatureRouteGate on its layout.tsx. Renders a connection badge, a scene grid, and stream/recording toggle buttons, all driven by GET /api/obs-remote/status and POST /api/obs-remote/{scene,stream,recording}. It contains no port/password form - connection details are edited in the Services area of the settings window's Connections group (/dashboard/settings/channel/connections/services).
  • /popout/obs - compact version of the same page, designed to be opened as a docked browser window next to OBS (e.g. via OBS's "Custom Browser Docks" or a standalone browser window). Same feature:obs_remote gate, same REST routes, polling status every 5 s. NOT designed to be loaded as a Browser Source inside OBS.
  • Settings → Connections → Services (/dashboard/settings/channel/connections/services) - the OBS section with Test, Edit and Remove. The edit dialog is where the user configures port / password / remote toggle / remote host; it needs obs:edit and the plan feature.

Both pages render the interactive control UI - connection badge, scene grid and stream/recording toggles, all driven by GET /api/obs-remote/status (a Next.js proxy route that resolves the GraphQL obsRemoteStatus query) - whenever the status reports available: true, and the dashboard additionally shows the live ingest stats (bitrate, FPS, dropped/total frames). The UI gates every "live" element on available, so it degrades gracefully: when no OBS worker is publishing a snapshot for the account (available: false), both pages fall back to an honest "remote control coming soon" state, and a control click on a disconnected worker returns 409 Conflict ("OBS remote control is not connected") rather than a fake success. The scene/stream/recording controls are additionally disabled while connected is false. The server-side worker transport that publishes those live snapshots and executes the commands is wired in ZAF-1122.

When /popout/obs is opened with a popout token (?token=lm_pop_…), the token is exchanged once for a short-lived popout session cookie (see Tokens). The page then gates on accountFeatures instead of me, and the popout-aware fetch tags requests so the proxy forwards the session cookie and self-heals a lapsed session. The session resolves to the same popout-token authorization context, so retrieving the decrypted OBS WebSocket password (GET /v1/integrations/obs/credentials, popout-token-only) keeps working - a dashboard login still cannot read it. Saved OBS-dock URLs carrying the old ?token= continue to work unchanged.

Both pages fail open on an unreachable API during the SSR feature check: when the feature query throws, the page renders rather than showing the disabled screen.

What the user sees when a flag is off​

  • feature:obs_remote off → both /dashboard/obs and /popout/obs show the generic FeatureDisabledPage with reason (global_off / plan_locked / account_override).
  • integration:obs_websocket off → "OBS WebSocket" entry disappears from the Integrations Add modal; attempts to save/delete config via direct API call return Feature 'integration:obs_websocket' is not available. Existing saved config is still readable. Overlay bootstrap payloads stop carrying OBS credentials.
  • widget:obs_browser_source off → no user-facing impact today (no overlay widget currently uses the window.obsstudio API). Reserved for future overlay-widget features that integrate with the Browser Source JS API.

Architecture​

Backend​

  • GraphQL (apps/api/src/graphql/obs.rs) - queries for config, status, remote connection status; mutations for save/delete config, test connection, and stream/recording/scene control. Write-mutations are gated by three guards chained via FeatureGuard::new("feature:obs_remote").and(FeatureGuard::new("integration:obs_websocket")).and(PermissionGuard::new("obs:edit")) so BOTH feature flags AND the permission must be satisfied.
  • REST (apps/api/src/routes/obs_integration.rs) - mirrors the GraphQL mutations with identical guard order: require_permission("obs:edit") → require_feature("feature:obs_remote") → require_feature("integration:obs_websocket"). Same error messages and codes as the GraphQL side per the parity rule.

Note: the server-side remote-control transport is live (ZAF-1122) and never fabricates state. The obsRemoteStatus query and GET /v1/obs-remote/status read the worker's live Redis snapshot - available: true with the real reading when a worker is publishing for the account, else available: false. The control mutations (obsControlStream, obsControlRecording, obsSwitchScene) and their /v1/obs-remote/* REST twins dispatch to the worker and only report success once it confirms execution against OBS; a missing/disconnected worker returns a 409 Conflict "OBS remote control is not connected" (CONFLICT) on both protocols. Invalid input returns a 400 with an identical message on both surfaces (scene_name is required; Invalid action: expected "start" or "stop").

  • OBS Remote Client (crates/lo-obs-remote/) - ObsRemoteClient handles WebSocket connections to OBS Studio, including authentication with the encrypted password. Used by both the OBS Remote worker (live status + control) and the connection test.
  • OBS Remote worker (apps/api/src/workers/obs_remote.rs) - a per-account worker that holds the OBS connection, polls ingest stats via IngestMonitor and emits events on threshold violations (low bitrate, high dropped frames, connection lost, recovery) with exponential-backoff reconnect. It also publishes a live ObsRemoteStatus snapshot to Redis (lumio:obs:status:{account_id}, TTL ≈ 3× poll interval) each cycle and subscribes to lumio:obs:command:{account_id} to execute scene/stream/recording controls, replying per-request so the handlers can confirm execution. Spawned and kept in sync by the reconcile loop (apps/api/src/workers/obs_reconcile.rs) for every remote_enabled OBS config.
  • Configuration storage - OBS config is stored as JSONB in the integration_configs table with platform = "obs", label = "OBS WebSocket", unique on (account_id, platform). The password field is encrypted using AES-256-GCM.

Frontend​

  • /dashboard/obs - Scene grid plus stream/recording toggles and a connection badge, driven by the /api/obs-remote/* proxy routes. No port/password form.
  • /popout/obs - Compact dock version of /dashboard/obs using the same /api/obs-remote/* routes through a popout-aware fetch, polling every 5 s. Designed to be opened as a docked browser window alongside OBS. NOT loaded as a Browser Source and does NOT use window.obsstudio.
  • Settings → Connections → Services - the OBS section and its edit dialog hold the port / password / remote-toggle / remote-host form.
  • /overlay/[key] - the only surface that actually drives OBS, via a browser-side obs-websocket connection (see Overlay control relay).

apps/web/src/hooks/use-obs-websocket.ts exports a useObsWebSocket hook that fetches credentials via the popout-token endpoints and manages a client lifecycle. No component calls it - only its handleObsRelayAction export is in use.

API​

GraphQL Queries​

QueryPermissionDescription
obsConfigobs:readFull OBS config: port, remote_enabled, remote_host, has_password flag, timestamps. Never exposes the actual password.
obsStatusobs:readLightweight status: configured flag, port, remote settings.
obsRemoteStatusobs:read + feature:obs_remoteLive remote connection status from the OBS worker's Redis snapshot: available (a worker is publishing), connected, stream/recording status, scene list, current scene, and ingest stats. Returns available: false with empty fields when no worker is running.

GraphQL Mutations​

MutationGuardsDescription
saveObsConfig(port?, password?, remoteEnabled?, remoteHost?)feature:obs_remote + integration:obs_websocket + obs:editSave (upsert) OBS config. Port defaults to 4455. Password is encrypted before storage.
deleteObsConfigfeature:obs_remote + integration:obs_websocket + obs:deleteDelete OBS integration config.
testObsConnectionfeature:obs_remote + obs:editTest the obs-websocket connection from the API server. Requires remote_enabled - otherwise it errors with Connection test is only available when remote mode is enabled. 10-second timeout; connection failures come back as { success: false, error } rather than a GraphQL error.
obsControlStream(action)feature:obs_remote + obs:editControl streaming: "start" or "stop". Dispatched to the OBS worker; returns true only once execution is confirmed, 400 on an invalid action, or 409 CONFLICT "OBS remote control is not connected" when no live worker acknowledges.
obsControlRecording(action)feature:obs_remote + obs:editControl recording: "start" or "stop". Same dispatch/confirmation/error semantics as obsControlStream.
obsSwitchScene(sceneName)feature:obs_remote + obs:editSwitch the active OBS scene. Empty sceneName returns 400 "scene_name is required"; otherwise dispatched to the worker with the same confirmation/409 semantics.

REST Endpoints​

MethodPathGuardsDescription
GET/v1/integrations/obsobs:read + feature:obs_remoteGet OBS config (has_password flag only, never the password).
PUT/v1/integrations/obsobs:edit + feature:obs_remote + integration:obs_websocketSave OBS config (upsert).
DELETE/v1/integrations/obsobs:delete + feature:obs_remote + integration:obs_websocketDelete OBS config. Returns 204.
GET/v1/integrations/obs/credentialspopout-token auth only + feature:obs_remoteGet decrypted OBS credentials. Carries no permission check - instead it rejects any auth context that is not a Popout Token with 403 This endpoint requires popout token authentication, so a normal dashboard login can never read the password.
GET/v1/integrations/obs/statusobs:read + feature:obs_remoteGet OBS configuration status (configured, port, remote settings).
POST/v1/integrations/obs/testobs:edit + feature:obs_remoteTest the obs-websocket connection from the API server.
GET/v1/obs-remote/statusobs:read + feature:obs_remoteLive remote connection status from the worker's Redis snapshot: available, connected, stream, recording, scenes, current_scene, ingest. Returns available: false with connected: false and empty scenes when no worker is running.
POST/v1/obs-remote/sceneobs:edit + feature:obs_remoteSwitch scene. Body { "scene_name": "..." }. Empty name → 400 "scene_name is required". Dispatched to the worker; 200 { "ok": true } once confirmed, else 409 Conflict "OBS remote control is not connected".
POST/v1/obs-remote/streamobs:edit + feature:obs_remoteStart/stop streaming. Body { "action": "start" | "stop" }. Invalid action → 400 "Invalid action: expected "start" or "stop"". Dispatched to the worker; 200 { "ok": true } once confirmed, else 409 Conflict "OBS remote control is not connected".
POST/v1/obs-remote/recordingobs:edit + feature:obs_remoteStart/stop recording. Body { "action": "start" | "stop" }. Same validation/confirmation/409 semantics as /v1/obs-remote/stream.

REST payloads are snake_case (remote_enabled, remote_host, has_password, scene_name, current_scene, created_at).

Read endpoints are intentionally NOT gated on integration:obs_websocket - existing configurations remain visible in the UI even when the transport flag is switched off for an account. Only WRITE paths (create/update/delete) are blocked so admins can disable new adoption without breaking existing users' views.

All four /v1/obs-remote/* handlers enforce the plan feature feature:obs_remote (require_feature, fail-closed) - the same guard the GraphQL surface applies via FeatureGuard - before doing anything else. There is no separate require_pro helper (a former no-op placeholder was removed); the feature flag is the plan gate. See Plan gating below.

Permissions​

PermissionDescription
obs:readRead OBS configuration and status
obs:editEdit OBS configuration, test connection, control stream/recording/scenes
obs:deleteDelete OBS configuration

Database​

TableDatabaseDescription
integration_configsPostgreSQLid, account_id, platform ("obs"), label ("OBS WebSocket"), enabled, config (JSONB), created_at, updated_at. Unique on (account_id, platform).

Config JSONB Structure​

{
"port": 4455,
"password": "encrypted_string_or_null",
"remote_enabled": false,
"remote_host": "192.168.1.100"
}
  • port - OBS WebSocket port (default 4455)
  • password - Encrypted OBS WebSocket password (null if not set)
  • remote_enabled - Whether remote (non-localhost) connections are enabled
  • remote_host - Remote host address (only used when remote_enabled is true)

Data Flow​

  1. User configures OBS settings (port, optional password, optional remote host) in Settings → Connections → Services → OBS.
  2. remote_host is validated (see Remote-host validation above), the password is encrypted via crypto::encrypt_versioned(), and the whole object is upserted into the integration_configs JSONB config.
  3. User can test the connection: the API server resolves remote_host and refuses a non-public answer (returning success: false with the reason, without decrypting the password), then decrypts the password and attempts an obs-websocket handshake to ws://{resolved-ip}:{port} with a 10-second timeout, then disconnects.
  4. Stream/recording/scene commands from /dashboard/obs or /popout/obs reach /v1/obs-remote/*, which - after the obs:edit + feature:obs_remote gates - publishes the command to the account's OBS worker over Redis (lumio:obs:command:{account_id}) and waits for a per-request reply. The worker executes it against the live OBS connection and confirms; the handler returns success only on confirmation, or 409 Conflict "OBS remote control is not connected" when no live worker acknowledges within the timeout. The worker is spawned per remote_enabled config by the reconcile loop.
  5. For overlays, the credentials travel in the WebSocket bootstrap payload and the browser connects to OBS directly; automation-issued obs:action events are executed there. See Overlay control relay.

Overlay-widget Browser Source integration​

Overlay widgets under /overlay/[key] can consume useObsBrowserSource() to react to OBS state (e.g. auto-hide when a specific scene is active). The ObsBrowserSourceProvider is already mounted at the overlay root - no additional setup is needed. To gate a widget's OBS-reactive behaviour on the feature flag, check useFeature("widget:obs_browser_source") inside the widget component.

Plan gating​

OBS is a paid-tier feature, and the gating lives entirely in the plan_features matrix: feature:obs_remote, integration:obs_websocket and widget:obs_browser_source are all seeded disabled for the Free plan and enabled for Pro. Free and Pro are the only plans. Because the flags gate every OBS query, mutation and route, a Free account cannot use OBS at all - the restriction is not limited to remote_enabled: true.

There is no separate plan-limit column. The historical account_limits.obs_remote_allowed boolean was migrated into the feature:obs_remote account override and dropped. Enforcement is the feature:obs_remote guard itself - require_feature on every /v1/obs-remote/* route and FeatureGuard on every GraphQL field, both fail-closed. (A former no-op require_pro placeholder in apps/api/src/routes/obs_remote.rs was removed in ZAF-1090; it protected nothing.)

Key Files​

PathDescription
apps/api/src/graphql/obs.rsGraphQL queries and mutations with three-guard chain
apps/api/src/routes/obs_integration.rsREST endpoints with identical guard chain
apps/api/src/routes/obs_remote.rsRemote control REST routes. Read the worker's live status snapshot and dispatch controls through services::obs_remote; 409 "not connected" only when no live worker acknowledges. Each still enforces feature:obs_remote.
apps/api/src/services/obs_remote.rsShared REST+GraphQL seam: read_status_snapshot() + send_command() (Redis request/reply with a bounded timeout) and the shared validation/error constants that keep both protocols parity-clean.
apps/api/src/workers/obs_remote.rsPer-account OBS worker: poll+status loop (live Redis snapshot + ingest monitoring/auto-actions) and command loop (executes scene/stream/recording controls, replies per-request).
apps/api/src/workers/obs_reconcile.rsProtocol-agnostic reconcile loop: spawns/restarts/stops OBS workers to match remote_enabled OBS integration configs; wired into API startup + graceful shutdown.
apps/api/src/services/bootstrap_provider.rsInjects the decrypted obs credentials block into the overlay WebSocket bootstrap
apps/api/src/dispatch.rsAutomation OBS action → Redis lumio:obs:action:{account_id}
crates/lo-obs-remote/Server-side ObsRemoteClient, IngestMonitor, ObsRemoteStatus
crates/lo-obs/Rust type definitions mirroring the Browser Source API. Currently has no dependents; the browser-side implementation lives in @lumio/obs.
shared/obs/src/client.tsTyped wrapper around window.obsstudio
shared/obs/src/ws-client.tsObsWebSocketClient - typed obs-websocket v5 client, used by the overlay
shared/obs/src/types.tsOBSStatus, OBSScene, OBSTransition, OBSEventType, OBSControlLevel
apps/web/src/hooks/use-obs-local-connect.tsBrowser-side obs-websocket connection for overlays
apps/web/src/hooks/use-obs-websocket.tshandleObsRelayAction relay dispatcher (plus an unused useObsWebSocket hook)
apps/api/src/crypto.rsAES-256-GCM password encryption/decryption
apps/web/src/contexts/obs-browser-source-context.tsxObsBrowserSourceProvider + useObsBrowserSource hook
apps/web/src/app/(overlay)/overlay/[key]/overlay-client.tsxMounts ObsBrowserSourceProvider, connects to local OBS, routes obs:action
apps/web/src/components/overlay/overlay-preview.tsxMounts ObsBrowserSourceProvider for the editor preview
apps/web/src/app/(main)/(app)/dashboard/obs/layout.tsxFeatureRouteGate feature="feature:obs_remote" on the whole segment
apps/web/src/app/(main)/(app)/dashboard/obs/obs-dashboard.tsxOBS Remote page (scene grid + stream/recording controls)
apps/web/src/app/(popout)/popout/obs/page.tsxCompact OBS Remote popout (SSR-gated on feature:obs_remote, popout-session aware). NOT a Browser Source.
apps/web/src/components/settings/sections/connections/services-tab.tsxServices area: OBS section and its port / password / remote-host dialog, StreamElements tokens