Skip to main content

Integrations

Overview​

The integrations module provides a generic configuration framework for platform-specific integrations (e.g., OBS, Discord bots). Each integration is scoped to an account with a platform identifier, a human-readable label, an enable/disable toggle, and a flexible JSONB config field for platform-specific settings.

The generic integration surface exposes read, update and toggle only - it has no create or delete operation. Rows in integration_configs are written by the feature that owns the integration: system connections (db::system_connections), the Kick worker, and the OBS integration all insert and delete their own row. OBS additionally has its own dedicated REST and GraphQL surface with full create/delete semantics; see OBS.

Architecture​

Dashboard UI
|
v
Next.js API Proxy (/api/integrations)
|
v
GraphQL (IntegrationQuery / IntegrationMutation)
|
v
db::integrations (PostgreSQL)

The config JSONB field stores platform-specific configuration. For example, an OBS integration might store WebSocket connection details, scene mappings, or source configurations. The API layer is agnostic to the config structure -- validation is handled by the consuming service.

Secrets in config​

Credentials inside config are encrypted at rest and never returned to a client. The rules live in apps/api/src/services/integration_secrets.rs:

  • Encrypted at rest. Secrets are stored as AES-256-GCM ciphertext (crypto::encrypt_versioned, the same key as every other credential), never as plaintext. The Shopify offline access and refresh tokens are written by ID-token exchange when a link completes or access is renewed, as access_token_encrypted / refresh_token_encrypted (see Shopify). The OBS password is encrypted by the OBS surface itself.
  • Never returned. Every Integration.config that the API returns (integrations, integration, and both mutation results) has its secrets removed. Each removed secret is replaced by a has_<name> boolean, for example has_access_token, has_refresh_token or has_password. A key counts as a secret when its name is password, secret, token or api_key, or ends in _secret, _token, _password, _api_key or _encrypted. Shopify configs always carry both has_access_token and has_refresh_token.
  • Dedicated surfaces. OBS and Shopify rows are written only through their own surfaces (OBS, Shopify), never through updateIntegration.

Shopify customer label (customer_display)​

A Shopify order event never contains the customer's full name or contact data. The Shopify config key customer_display, set with updateShopifySettings / PUT /v1/connections/shopify/settings (see Shopify), decides how much of the name an order event carries:

ValueLabel for "Alex Miller"
none (default)no label
first_nameAlex
first_initialA.
first_name_last_initialAlex M.
custom_namethe name the buyer typed for the stream (sanitized), never the real name
  • Fail-closed. A missing key or any other stored value is read as none. The settings surface rejects an unknown value with BAD_REQUEST (Config field 'customer_display' must be one of: none, first_name, first_initial, first_name_last_initial, custom_name).
  • Only with the buyer's consent. The cart and checkout blocks write two private cart attributes to the order: __lumio_name_consent (the consent record "<form>:<text version>:<scope>", e.g. first_initial:2:i; 0 or missing = no consent) and __lumio_stream_name (the typed name). lo_shopify::Order keeps only these two of the order's note_attributes; every other attribute is dropped when the payload is parsed, and neither is stored in the event or logged. The record names the form the block showed, the consent-text version (only 2 is accepted; version 1 never shipped) and the order details the text named: o (order only), i (items), p (price), ip (items and price), from the store's show_items / show_price. Every form except none needs a record:
    • Scope. The recorded scope must cover the current details, one switch at a time: a buyer who agreed to "with the items" gets no label once the streamer turns the price on; a buyer who agreed to "items and price" still gets one when the price is turned off.
    • Real-name forms need a record with one of the three real-name forms and are shown in the less revealing of the consented and the current form (first_initial < first_name < first_name_last_initial): consent to Alex M. covers A., consent to A. never covers Alex.
    • custom_name needs a custom_name record next to the typed name.
    • A typed name under a real-name form, or a checkbox record under custom_name, is a mismatch and shows no label.
  • Typed names (custom_name) go through lo_shopify::sanitize_stream_name: NFKC; whitespace collapsed; control, format and default-ignorable characters removed; a bidi control drops the name. Then only letters, marks, numbers, space, -, ', . and _ are allowed, the first character must be a letter or number, and the result must have at most 25 grapheme clusters and 100 UTF-8 bytes. Anything else shows no label (never a truncated or stripped one). The name must also pass the account's word filter: the config of its enabled system-word-filter install (bannedWords, useWildcards, bannedPatterns; lo_shopify::WordFilter), read on every such order without a cache, so a new word applies to the next order. Without an enabled install no word is filtered. If the lookup fails, the order shows no label.
  • Shopify's protected customer data. Name fields reach Lumio only if Shopify approved the app for protected customer data Level 2 (name). Without that approval Shopify redacts the name in the order webhook, and every real-name form yields no label.
  • No first name, no label. Without a customer first name every real-name form yields no label. The label never falls back to the last name or the email address. Without a last name, first_name_last_initial gives the first name alone.
  • Initials are the first character (grapheme) of the name, uppercased, followed by ., so Łukasz gives Ł.. A multi-word first name is kept whole (Mary Ann).

The label is built by lo_shopify::order_customer_label when the webhook arrives; see Webhooks → Shopify for what an order event stores.

API​

GraphQL Queries​

All four operations are gated by FeatureGuard::new("feature:integrations").and(PermissionGuard::new(<permission>)), so an account whose plan lacks feature:integrations gets FEATURE_DISABLED before the permission is even considered.

QueryArgsReturnsFeaturePermission
integrations--[Integration!]!feature:integrationssettings:read
integrationid: UUID!Integrationfeature:integrationssettings:read
  • integrations returns all configs for the active account, ordered by platform.
  • integration returns a single config by ID, filtered by account ownership.

GraphQL Mutations​

MutationArgsReturnsFeaturePermission
updateIntegrationinput: UpdateIntegrationInput!Integration!feature:integrationssettings:edit
toggleIntegrationid: UUID!Integration!feature:integrationssettings:edit
  • updateIntegration supports partial updates via COALESCE -- only provided fields are changed.
  • updateIntegration replaces config as a whole when it is sent.
  • updateIntegration refuses a config for an OBS row with BAD_REQUEST (The OBS config can only be changed in the OBS settings) and leaves the row unchanged. OBS config is written only through the OBS surface, which validates remote_host against SSRF and encrypts password. enabled and label can still be changed here.
  • updateIntegration refuses a config for a Shopify row the same way (The Shopify connection can only be changed in the Shopify settings). Only the Shopify link completion and token renewal write the tokens and shop identity; the dedicated Shopify settings write customer_display, show_items and show_price. A generic settings:edit member cannot point the row at another shop.
  • toggleIntegration flips the enabled boolean without requiring the caller to know the current state.

Both mutations verify account ownership before proceeding.

REST Endpoints​

The generic integration surface is GraphQL-only - there is no /v1/integrations REST resource for listing, updating or toggling an arbitrary integration. The paths that do exist under /v1/integrations/ all belong to the OBS integration and are documented in OBS:

MethodPath
GET / PUT / DELETE/v1/integrations/obs
GET/v1/integrations/obs/credentials
GET/v1/integrations/obs/status
POST/v1/integrations/obs/test

The web dashboard reaches the generic surface through its Next.js proxy routes (/api/integrations, /api/integrations/[id]), which execute the GraphQL operations server-side.

WebSocket​

There is no integration channel. channel_gate_for in crates/lo-websocket/src/gate.rs maps no integrations channel type, so a subscription attempt is rejected as Unknown. Integrations that stream data do so on their own channels - OBS through its status polling, Discord through events:{account_id}.

GraphQL Types​

type Integration {
id: UUID!
accountId: UUID!
platform: String!
label: String!
enabled: Boolean!
config: JSON!
createdAt: String!
updatedAt: String!
}

input UpdateIntegrationInput {
id: UUID!
label: String
enabled: Boolean
config: JSON
}

Permissions​

PermissionDescription
settings:readView integration configurations
settings:editUpdate integration configurations

Integrations share the settings:* permission namespace. Included in: Owner, Administrator roles.

Beyond the permission, every operation requires the feature:integrations flag on the account's plan.

Database​

Table: integration_configs​

ColumnTypeDescription
idUUID (PK)Integration config ID
account_idUUID (FK)Owning account
platformTEXTPlatform identifier (e.g., obs, discord)
labelTEXTHuman-readable label
enabledBOOLEANWhether the integration is active
configJSONBPlatform-specific configuration
created_atTIMESTAMPTZCreation timestamp
updated_atTIMESTAMPTZLast update timestamp

DB Functions​

FunctionDescription
list_integrationsList all configs for an account, ordered by platform ASC
get_integrationGet a single config by ID
update_integrationPartial update using COALESCE for label, enabled, config
toggle_integrationFlip enabled to NOT enabled

Key Files​

FilePurpose
apps/api/src/graphql/integrations.rsGraphQL queries, mutations, input/output types
apps/api/src/db/integrations.rsDatabase CRUD operations
crates/lo-auth/src/rbac.rsPermission constants (settings:read, settings:edit)