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 per store with updateShopifySettings / PUT /v1/connections/shopify/{shop_host}/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
buyer_choicethe form the buyer picked in the block, from the forms the merchant allows (buyer_choice_forms)
  • Stream only. This setting and the consent rules below govern the public label. The team's internal buyer name is derived from the setting at order time under every value, none included, needs no consent and is never part of raw.
  • 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, buyer_choice).
  • Allowed forms under buyer_choice. The config key buyer_choice_forms holds the forms the buyer may pick: 1–4 different values of first_initial, first_name, first_name_last_initial, custom_name (none is always offered and never stored). It is stored in that fixed order (lo_shopify::BuyerChoiceForms, a set) and kept when the merchant switches to a fixed form, so switching back restores it. Setting buyer_choice without a list, sent or stored, is refused (see Shopify → Settings). A missing, empty or unreadable stored list under buyer_choice reads as "no allowed form": no label, never "all forms".
  • 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 (2 = the checkbox or text field of a fixed form, 3 = the checkbox and form select under buyer_choice; version 1 never shipped, any other version is no consent) 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.
    • Under buyer_choice the picked form applies as is (not the less revealing rule): a real-name record shows that form, a custom_name record shows the typed name. The form must be in the current buyer_choice_forms; a form the merchant removed since, or a forged one, shows no label (no fallback to another form). A real-name record without a typed name and a custom_name record with one are the only matching kinds; anything else shows no label.
    • Switching the setting. A version-3 record under a fixed form follows the less revealing rule above. A version-2 record under buyer_choice counts as the buyer's pick, if its form is allowed.
  • Every label goes through lo_shopify::sanitize_stream_name, the typed name (custom_name) and a real-name form alike, fixed or picked under buyer_choice (Shopify's first and last name are free text the buyer types at checkout): 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). Every label, typed or derived, the fixed real-name forms included, then passes three lists: Lumio's global blocklist, the account's own blocklist for buyer names and the account's word filter (the config of its enabled system-word-filter install: bannedWords, useWildcards, bannedPatterns; lo_shopify::WordFilter). The account list and the word filter are read in one query, 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. They are read at most once per order event and only when the order would otherwise show a name (lo_shopify::order_label_candidate). See Shopify → Which lists can drop a name.
  • 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)