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:
| Path | Who connects to obs-websocket | State |
|---|---|---|
/dashboard/obs and /popout/obs | nobody yet — both pages call the REST routes under /v1/obs-remote/*, whose server-side transport is planned but not yet wired | Status honestly reports available: false (remote control not yet live); the scene/stream/recording controls return 409 Conflict "OBS remote control is not yet available" instead of pretending to succeed |
Overlay pages (/overlay/[key]) | the browser, from inside OBS, using ObsWebSocketClient from @lumio/obs | Working. Credentials arrive in the WebSocket bootstrap payload; obs:action events pushed by automations are executed locally |
testObsConnection / POST /v1/integrations/obs/test | the API server, using lo_obs_remote::ObsRemoteClient | Working, but only a connect-and-disconnect probe with a 10-second timeout |
| Piece | Flag | What it is |
|---|---|---|
| OBS Remote feature umbrella | feature:obs_remote | Gates 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 transport | integration:obs_websocket | The stored obs-websocket connection details (port + password + optional remote host). Configured by the streamer in the /dashboard/connections Integrations modal. Also gates whether overlay bootstrap payloads carry OBS credentials. |
| Browser Source overlay capability | widget:obs_browser_source | Reserved 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_remoteoff →/dashboard/obs(viaFeatureRouteGateon itslayout.tsx) and/popout/obs(via an SSR feature check) render<FeatureDisabledPage>, and every OBS query, mutation and REST route rejects the request.integration:obs_websocketoff → write endpoints for the WebSocket integration (PUT /v1/integrations/obs,DELETE /v1/integrations/obs, GraphQLsaveObsConfig,deleteObsConfig) reject the request, and overlay bootstrap payloads omit theobscredentials block. Read endpoints still return existing config for visibility. The user can't add/save/delete the WebSocket connection in the Integrations modal.widget:obs_browser_sourceoff → no observable effect. No widget renderer, settings panel or editor-picker entry consumes this flag, andObsBrowserSourceProvidermounts unconditionally at the overlay root (it gates internally onwindow.obsstudioavailability). A future overlay widget that reacts to OBS state should checkuseFeature("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:
/dashboard/connections→ Integrations section → Add integration → OBS WebSocket. Fields:port(default 4455),password(optional),remote_enabledtoggle,remote_host(only when remote_enabled is true). - Password encryption: AES-256-GCM via
crypto::encrypt()with a key derived fromauth.token_encryption_key. Stored in the JSONB config column ofintegration_configs. - Save semantics:
PUT /v1/integrations/obs/saveObsConfigwrites the whole config object as an upsert. Submitting an empty or omittedpasswordclears the stored password rather than leaving the previous one in place. - Remote-host validation: when
remote_hostis set it is resolved and rejected if it points at a loopback, private, link-local, unspecified or broadcast address (IPv4 and IPv6, including ULAfc00::/7andfe80::/10) — an SSRF guard, soremote_hostmust be a public address. - Transport: TCP WebSocket to
ws://{host}:{port}. Host defaults tolocalhost; 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:
- Detects
window.obsstudiopresence at mount time (SSR-safe: no-op on the server). - Fetches initial state in parallel: plugin version, control level, stream/recording/replay-buffer/virtualcam status, current scene, scene list, transition list, current transition.
- Subscribes to every event in the
OBSEventTypeunion exceptobsReplaybufferSaved(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, andobsExit(which resets the provider to a disconnected state). - Runs status polling every 2 000 ms as a drift-correction backup.
- 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:
| Field | Type | Description |
|---|---|---|
available | boolean | True when window.obsstudio was detected |
pluginVersion | string | null | obs-browser plugin version |
controlLevel | OBSControlLevel | Current control permissions (NONE/READ_OBS/READ_USER/BASIC/ADVANCED/ALL) |
status | OBSStatus | null | streaming / recording / recordingPaused / replayBuffer / virtualcam booleans |
currentScene | string | null | Active scene name |
currentSceneInfo | OBSScene | null | Active scene object including canvas width and height (useful when canvas dimensions are needed) |
scenes | string[] | All scene names |
transitions | string[] | All available transition names |
currentTransition | string | null | Active transition name |
canRead() | () => boolean | controlLevel >= READ_OBS (1) |
canReadUser() | () => boolean | controlLevel >= READ_USER (2) |
canControl() | () => boolean | controlLevel >= BASIC (3) |
canModify() | () => boolean | controlLevel >= ADVANCED (4) |
canFullControl() | () => boolean | controlLevel == 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:
- The account has an OBS integration config and at least one overlay layer of type
obs_browser_source(orobs). - When that overlay's WebSocket session starts,
OverlayBootstrapProvider(apps/api/src/services/bootstrap_provider.rs) decrypts the stored password and adds anobsblock (port,password,remote_enabled,remote_host) to the bootstrap payload — but only whenintegration:obs_websocketis 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. 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.- 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 anobs:actionWebSocket event. 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
| Path | Description |
|---|---|
apps/web/src/contexts/obs-browser-source-context.tsx | Provider + context + useObsBrowserSource hook |
apps/web/src/app/(overlay)/overlay/[key]/overlay-client.tsx | Mounts ObsBrowserSourceProvider, calls useObsLocalConnect, routes obs:action events |
apps/web/src/components/overlay/overlay-preview.tsx | Mounts ObsBrowserSourceProvider for the editor preview |
apps/web/src/hooks/use-obs-local-connect.ts | Browser-side obs-websocket connection from bootstrap credentials |
shared/obs/src/client.ts | ObsBrowserSource class (typed window.obsstudio wrapper) |
shared/obs/src/types.ts | OBSStatus, 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 accountsintegration: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}/featuresto 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 onfeature:obs_remoteviaFeatureRouteGateon itslayout.tsx. Renders a connection badge, a scene grid, and stream/recording toggle buttons, all driven byGET /api/obs-remote/statusandPOST /api/obs-remote/{scene,stream,recording}. It contains no port/password form — connection details are edited on/dashboard/connections./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). Samefeature:obs_remotegate, same REST routes, polling status every 5 s. NOT designed to be loaded as a Browser Source inside OBS./dashboard/connections— the Integrations modal's "OBS WebSocket" entry, shown only whenuseFeatureStatus("integration:obs_websocket")reports enabled. Where the user configures port / password / remote toggle / remote host.
Because the /v1/obs-remote/* server-side transport is not yet wired, GET /v1/obs-remote/status returns available: false and both pages render an honest "remote control coming soon" state; a control click returns 409 Conflict ("OBS remote control is not yet available") rather than a fake success.
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_remoteoff → both/dashboard/obsand/popout/obsshow the genericFeatureDisabledPagewith reason (global_off/plan_locked/account_override).integration:obs_websocketoff → "OBS WebSocket" entry disappears from the Integrations Add modal; attempts to save/delete config via direct API call returnFeature 'integration:obs_websocket' is not available. Existing saved config is still readable. Overlay bootstrap payloads stop carrying OBS credentials.widget:obs_browser_sourceoff → no user-facing impact today (no overlay widget currently uses thewindow.obsstudioAPI). 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 viaFeatureGuard::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 not yet wired, so the surface reports its state honestly rather than fabricating one (ZAF-1090). The
obsRemoteStatusquery andGET /v1/obs-remote/statusreturnavailable: false; the control mutations (obsControlStream,obsControlRecording,obsSwitchScene) and their/v1/obs-remote/*REST twins return a409 Conflict"OBS remote control is not yet available" (CONFLICT) on both protocols instead of a fake success. The WorkerManager integration is a planned follow-up; until it ships, no caller receives a success from these control endpoints.
- OBS Remote Client (
crates/lo-obs-remote/) —ObsRemoteClienthandles WebSocket connections to OBS Studio, including authentication with the encrypted password. Today its only live caller is the connection test. - OBS Remote worker (
apps/api/src/workers/obs_remote.rs) — a per-account worker that would hold the OBS connection, poll ingest stats viaIngestMonitorand emit events on threshold violations (low bitrate, high dropped frames, connection lost, recovery) with exponential-backoff reconnect. The code is complete but nothing spawns it, which is why remote control is not yet available and the control endpoints return409 Conflict. - Configuration storage — OBS config is stored as JSONB in the
integration_configstable 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/obsusing 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 usewindow.obsstudio./dashboard/connections— Integrations modal with "OBS WebSocket" entry gated byuseFeatureStatus("integration:obs_websocket"); theObsConfigCardholds 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
| Query | Permission | Description |
|---|---|---|
obsConfig | obs:read | Full OBS config: port, remote_enabled, remote_host, has_password flag, timestamps. Never exposes the actual password. |
obsStatus | obs:read | Lightweight status: configured flag, port, remote settings. |
obsRemoteStatus (STUB) | obs:read | Remote connection status from OBS worker: connected flag, stream/recording status, scene list, current scene. Currently always returns disconnected/empty — WorkerManager integration pending. |
GraphQL Mutations
| Mutation | Guards | Description |
|---|---|---|
saveObsConfig(port?, password?, remoteEnabled?, remoteHost?) | feature:obs_remote + integration:obs_websocket + obs:edit | Save (upsert) OBS config. Port defaults to 4455. Password is encrypted before storage. |
deleteObsConfig | feature:obs_remote + integration:obs_websocket + obs:delete | Delete OBS integration config. |
testObsConnection | feature:obs_remote + obs:edit | Test 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) (STUB) | feature:obs_remote + obs:edit | Control streaming: "start" or "stop". Currently always returns success — WorkerManager integration pending. |
obsControlRecording(action) (STUB) | feature:obs_remote + obs:edit | Control recording: "start" or "stop". Currently always returns success — WorkerManager integration pending. |
obsSwitchScene(sceneName) (STUB) | feature:obs_remote + obs:edit | Switch the active OBS scene. Currently always returns success — WorkerManager integration pending. |
REST Endpoints
| Method | Path | Guards | Description |
|---|---|---|---|
GET | /v1/integrations/obs | obs:read + feature:obs_remote | Get OBS config (has_password flag only, never the password). |
PUT | /v1/integrations/obs | obs:edit + feature:obs_remote + integration:obs_websocket | Save OBS config (upsert). |
DELETE | /v1/integrations/obs | obs:delete + feature:obs_remote + integration:obs_websocket | Delete OBS config. Returns 204. |
GET | /v1/integrations/obs/credentials | popout-token auth only + feature:obs_remote | Get 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/status | obs:read + feature:obs_remote | Get OBS configuration status (configured, port, remote settings). |
POST | /v1/integrations/obs/test | obs:edit + feature:obs_remote | Test the obs-websocket connection from the API server. |
GET | /v1/obs-remote/status (not yet wired) | obs:read + feature:obs_remote | Remote connection status. Returns available: false with connected: false and empty scenes while the transport is not yet live. |
POST | /v1/obs-remote/scene (not yet wired) | obs:edit + feature:obs_remote | Switch scene. Body { "scene_name": "..." }. Returns 409 Conflict "OBS remote control is not yet available". |
POST | /v1/obs-remote/stream (not yet wired) | obs:edit + feature:obs_remote | Start/stop streaming. Body { "action": "start" | "stop" }. Returns 409 Conflict "OBS remote control is not yet available". |
POST | /v1/obs-remote/recording (not yet wired) | obs:edit + feature:obs_remote | Start/stop recording. Body { "action": "start" | "stop" }. Returns 409 Conflict "OBS remote control is not yet available". |
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
| Permission | Description |
|---|---|
obs:read | Read OBS configuration and status |
obs:edit | Edit OBS configuration, test connection, control stream/recording/scenes |
obs:delete | Delete OBS configuration |
Database
| Table | Database | Description |
|---|---|---|
integration_configs | PostgreSQL | id, 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 enabledremote_host— Remote host address (only used when remote_enabled is true)
Data Flow
- User configures OBS settings (port, optional password, optional remote host) in
/dashboard/connections→ Integrations → OBS WebSocket. remote_hostis validated against the private/loopback/link-local block-list, the password is encrypted viacrypto::encrypt(), and the whole object is upserted into theintegration_configsJSONB config.- User can test the connection: the API server decrypts the password and attempts an obs-websocket handshake to
ws://{remote_host}:{port}with a 10-second timeout, then disconnects. - Stream/recording/scene commands from
/dashboard/obsor/popout/obsreach/v1/obs-remote/*, which — after theobs:edit+feature:obs_remotegates — returns409 Conflict"OBS remote control is not yet available" because the command channel to the OBS worker is not yet wired. Nothing is forwarded to OBS. - For overlays, the credentials travel in the WebSocket bootstrap payload and the browser connects to OBS directly; automation-issued
obs:actionevents 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
| Path | Description |
|---|---|
apps/api/src/graphql/obs.rs | GraphQL queries and mutations with three-guard chain |
apps/api/src/routes/obs_integration.rs | REST endpoints with identical guard chain |
apps/api/src/routes/obs_remote.rs | Remote control routes. Not yet wired to WorkerManager/Redis, so they report honestly: status returns available: false; scene/stream/recording controls return 409 Conflict. Each still enforces feature:obs_remote. |
apps/api/src/workers/obs_remote.rs | Per-account OBS worker with ingest monitoring and auto-actions — implemented but never spawned |
apps/api/src/services/bootstrap_provider.rs | Injects the decrypted obs credentials block into the overlay WebSocket bootstrap |
apps/api/src/dispatch.rs | Automation 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.ts | Typed wrapper around window.obsstudio |
shared/obs/src/ws-client.ts | ObsWebSocketClient — typed obs-websocket v5 client, used by the overlay |
shared/obs/src/types.ts | OBSStatus, OBSScene, OBSTransition, OBSEventType, OBSControlLevel |
apps/web/src/hooks/use-obs-local-connect.ts | Browser-side obs-websocket connection for overlays |
apps/web/src/hooks/use-obs-websocket.ts | handleObsRelayAction relay dispatcher (plus an unused useObsWebSocket hook) |
apps/api/src/crypto.rs | AES-256-GCM password encryption/decryption |
apps/web/src/contexts/obs-browser-source-context.tsx | ObsBrowserSourceProvider + useObsBrowserSource hook |
apps/web/src/app/(overlay)/overlay/[key]/overlay-client.tsx | Mounts ObsBrowserSourceProvider, connects to local OBS, routes obs:action |
apps/web/src/components/overlay/overlay-preview.tsx | Mounts ObsBrowserSourceProvider for the editor preview |
apps/web/src/app/(main)/(app)/dashboard/obs/layout.tsx | FeatureRouteGate feature="feature:obs_remote" on the whole segment |
apps/web/src/app/(main)/(app)/dashboard/obs/obs-dashboard.tsx | OBS Remote page (scene grid + stream/recording controls) |
apps/web/src/app/(popout)/popout/obs/page.tsx | Compact OBS Remote popout (SSR-gated on feature:obs_remote, popout-session aware). NOT a Browser Source. |
apps/web/src/app/(main)/(app)/dashboard/connections/obs-config.tsx | Port / password / remote-host form |