Connections and bindings

A connection is one connected account on one network — a Facebook page, a WhatsApp sender, an SMS short code, a LiveChat widget. It is owned by exactly one tenant and is unique on (tenantId, network, externalAccountId).

The connection row itself carries no secrets. Credentials live one level down, on ConnectionBinding: one row per (connectionId, capability), holding an encrypted blob in secretEnc. Two things fall out of that split. A connection can hold credentials from different integrations at once (native Meta DMs alongside aggregator comments on the same page), and reading the connections list never decrypts anything — the vault is touched only when a capability call actually needs to talk to a provider.

SocialConnection            ConnectionBinding
  id                          connectionId   ─┐
  tenantId                    capability      │ @@unique([connectionId, capability])
  network                     integrationKey ─┘
  externalAccountId           externalRef
  ownerUserId?                secretEnc        ← AES-256-GCM
  accountName/Handle/avatar   status
  connected                   statusReason?

Capabilities

There are five, defined in packages/types/src/capabilities.ts as IntegrationCapability:

Capability What it means
publish Post to the network's feed
messages Direct messages — list, fetch, send, mark read
comments Comments on posts — list, reply, hide, like, delete, private-reply
reviews Public reviews — list and reply (Google Business today)
connection Connect, disconnect, verify webhooks. Served by the adapter itself

Encryption

KeyVaultService (apps/service/src/crypto/key-vault.service.ts) encrypts with AES-256-GCM under a key-encryption key from INBOX_KEK, which must be 32 bytes as 64 hex characters — the service refuses to boot otherwise. The packed form stored in secretEnc is base64(iv[12] | tag[16] | ciphertext). It is reversible by design: adapter calls need the plaintext credential back to sign a provider request.

connectBindingScope

Every adapter declares how a fresh connect occupies capability slots. This is a per-adapter constant, not a branch on the adapter's kind, so a new integration has to state its strategy rather than inherit one silently.

Scope Behaviour Who uses it
wildcard One * binding serves every capability routed to this integration aggregator
owned-capabilities One binding per capability the integration owns on that network every native adapter

The aggregator holds a single tenant-level profile credential, so splitting it across four rows would store the same secret four times. A native adapter's credential is genuinely per-capability scoped, so it gets a row each.

Resolution

IntegrationRegistry.resolveForConnection(tenantId, connectionId, capability) is the one path from "I want to send on this connection" to "here is the adapter and its decrypted credential". In order:

  1. Load the connection by id. If it is missing or belongs to another tenant, throw not_found. Not forbidden — a cross-tenant probe must not learn that the id exists.
  2. If connection.connected is false, throw capability_unavailable. A paused account serves nothing, even if a binding is still marked available.
  3. List the connection's bindings and keep only status === 'available'.
  4. Pick the binding whose capability matches exactly; fall back to the * binding.
  5. If neither exists, throw capability_unavailable — with a hint (see below).
  6. Check adapter.supports(capability, connection.network). Then check the provider object itself exists (publishing / dm / comments / reviews). supports() is a claim; the second check is defence in depth against an adapter that claims a capability it has no code for.
  7. Decrypt only that binding's secretEnc and return { adapter, ctx, integrationKey, network }.

Note step 7's ctx.network comes from the connection, not from the credential — the connection is the canonical answer to "what network is this".

The capability_unavailable hint

When no binding serves the capability, the error's details carry connectIntegrationKey: the integration you would need to connect to gain that capability, computed by effectiveIntegrationKey(network, capability). A native-DM-only Instagram account asked to publish gets back {"connectIntegrationKey": "aggregator"}, which is actionable in a way a bare error code is not.

The hint is suppressed when the candidate integration is already bound on this connection — telling you to connect something you have already connected is noise, and usually means the real problem is a binding stuck in a non-available status.

{
  "error": {
    "code": "capability_unavailable",
    "message": "...",
    "requestId": "...",
    "details": { "connectIntegrationKey": "aggregator" }
  }
}

Ownership and visibility

SocialConnection.ownerUserId is an Accounts user id — inbox stores no users of its own, so every *UserId column in the schema is a foreign identity. It is set to whoever completed the connect.

Who sees a connection in GET /v1/connections depends on how you authenticated:

Caller Sees
Machine key (sk_) Every connection in the tenant. No user context to filter by
Accounts user session Their own connections, plus those owned by co-members of their Accounts groups, plus every connection with a null owner

A null ownerUserId falls back to tenant-wide visibility on purpose: connections that predate ownership tracking, or were imported by a machine, must not become invisible to everyone.

Group expansion works by forwarding the caller's own session bearer to Accounts and reading back their groups and co-members (UserGroupsService.visibleOwnerIds). Accounts exposes no per-user group lookup, so "who are my co-members" is both the available question and the safe one — a caller can only ever enumerate their own groups. If Accounts is unreachable the lookup returns an empty set, which fails closed: you see only your own connections, never more.

Set INBOX_SHARE_CONNECTIONS_WITH_GROUPS=false to disable group expansion entirely, so a session sees only connections it owns. Any other value (including unset) leaves it on — the config reads env.INBOX_SHARE_CONNECTIONS_WITH_GROUPS !== 'false'.

See also Adapter catalogue and Connecting an account.