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, asaccess_token_encrypted/refresh_token_encrypted(see Shopify). The OBSpasswordis encrypted by the OBS surface itself. - Never returned. Every
Integration.configthat the API returns (integrations,integration, and both mutation results) has its secrets removed. Each removed secret is replaced by ahas_<name>boolean, for examplehas_access_token,has_refresh_tokenorhas_password. A key counts as a secret when its name ispassword,secret,tokenorapi_key, or ends in_secret,_token,_password,_api_keyor_encrypted. Shopify configs always carry bothhas_access_tokenandhas_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:
| Value | Label for "Alex Miller" |
|---|---|
none (default) | no label |
first_name | Alex |
first_initial | A. |
first_name_last_initial | Alex M. |
custom_name | the 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 withBAD_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;0or missing = no consent) and__lumio_stream_name(the typed name).lo_shopify::Orderkeeps only these two of the order'snote_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 (only2is accepted; version1never shipped) and the order details the text named:o(order only),i(items),p(price),ip(items and price), from the store'sshow_items/show_price. Every form exceptnoneneeds 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 toAlex M.coversA., consent toA.never coversAlex. custom_nameneeds acustom_namerecord 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 throughlo_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 enabledsystem-word-filterinstall (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_initialgives the first name alone. - Initials are the first character (grapheme) of the name, uppercased, followed by
., soŁukaszgivesŁ.. 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.
| Query | Args | Returns | Feature | Permission |
|---|---|---|---|---|
integrations | -- | [Integration!]! | feature:integrations | settings:read |
integration | id: UUID! | Integration | feature:integrations | settings:read |
integrationsreturns all configs for the active account, ordered by platform.integrationreturns a single config by ID, filtered by account ownership.
GraphQL Mutations
| Mutation | Args | Returns | Feature | Permission |
|---|---|---|---|---|
updateIntegration | input: UpdateIntegrationInput! | Integration! | feature:integrations | settings:edit |
toggleIntegration | id: UUID! | Integration! | feature:integrations | settings:edit |
updateIntegrationsupports partial updates viaCOALESCE-- only provided fields are changed.updateIntegrationreplacesconfigas a whole when it is sent.updateIntegrationrefuses aconfigfor an OBS row withBAD_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 validatesremote_hostagainst SSRF and encryptspassword.enabledandlabelcan still be changed here.updateIntegrationrefuses aconfigfor 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 writecustomer_display,show_itemsandshow_price. A genericsettings:editmember cannot point the row at another shop.toggleIntegrationflips theenabledboolean 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:
| Method | Path |
|---|---|
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
| Permission | Description |
|---|---|
settings:read | View integration configurations |
settings:edit | Update 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
| Column | Type | Description |
|---|---|---|
id | UUID (PK) | Integration config ID |
account_id | UUID (FK) | Owning account |
platform | TEXT | Platform identifier (e.g., obs, discord) |
label | TEXT | Human-readable label |
enabled | BOOLEAN | Whether the integration is active |
config | JSONB | Platform-specific configuration |
created_at | TIMESTAMPTZ | Creation timestamp |
updated_at | TIMESTAMPTZ | Last update timestamp |
DB Functions
| Function | Description |
|---|---|
list_integrations | List all configs for an account, ordered by platform ASC |
get_integration | Get a single config by ID |
update_integration | Partial update using COALESCE for label, enabled, config |
toggle_integration | Flip enabled to NOT enabled |
Key Files
| File | Purpose |
|---|---|
apps/api/src/graphql/integrations.rs | GraphQL queries, mutations, input/output types |
apps/api/src/db/integrations.rs | Database CRUD operations |
crates/lo-auth/src/rbac.rs | Permission constants (settings:read, settings:edit) |