decentrl/docs
SDK

Transport Layer

How a client talks to mediators — directly over HTTP and WebSocket, through the browser extension, or through your own transport.

A DecentrlClient never talks to a mediator itself. Everything that leaves the client — creating an identity, publishing, draining pending events, contracts, reading stored and public events — goes through a transport, an object implementing DecentrlTransport from @decentrl/sdk. The client decides what to do (validation, state, dedupe, sync scheduling); the transport decides how it reaches the network and who holds the keys.

Direct transport (default)

Without a transport option, app.createClient uses a DirectTransport: the identity's keys live in the app, and it talks to mediators itself.

  • HTTP for every command and query: signed command envelopes POSTed to the mediator endpoint named in the mediator's DID document, and deliveries POSTed to each recipient's own mediator.
  • WebSocket for real-time push, opened by startSync() (disable it with startSync({ websocket: false })); polling continues as a fallback.
const client = chatApp.createClient({
  mediatorDid: 'did:web:mediator.decentrl.network',
  persist: { key: 'my-app' },
})

With persist, the direct transport also keeps each contract's current keys (never root secrets) and pending requests' ephemeral keys in localStorage under the same key, so contacts survive a reload. Tabs sharing the storage merge the contract keys instead of overwriting each other's.

Extension transport

With the Decentrl browser extension, keys never enter the app: the extension holds the identity, encrypts, signs and syncs, and the app talks to it over a port. @decentrl/extension-client creates the transport:

import { createExtensionTransport } from '@decentrl/extension-client'

const transport = await createExtensionTransport({
  extensionId: import.meta.env.VITE_EXTENSION_ID,
  appId: 'my-app',
  // Your event types live under this prefix: `my-app.*` is granted with it
  prefix: 'my-app',
  // Beyond your own event types
  permissions: ['identity', 'contracts', 'events'],
})

if (!transport) {
  // The extension isn't installed (or didn't answer within `detectTimeout`, 2 s by default)
}

const client = chatApp.createClient({ transport, persist: { key: 'my-app' } })

On first connect the extension asks the user to approve the app and the scopes it requests:

ScopeAllows
<prefix>.*publishing and receiving your app's own event types
identityreading the user's identity (DID, alias)
identity.createcreating an identity from the app
contractslisting, requesting, accepting and revoking contacts
eventsreading stored events back and tagging them — the client's startup scan and useEventQuery need it
groupslisting, reading, posting to, joining and leaving the app's groups, answering invitations, accepting a handover
groups.adminadministering them: inviting, join requests, approving and declining, removing, rotating, handing over, deleting a group

An operation outside the granted scopes waits for the user: the extension asks for the extra scope, runs the operation if it is granted, and fails it if it is denied.

Declare every scope the app uses. When a later version declares more than the user approved, the extension asks for the new ones on connect; if the user keeps the current permissions, the app connects with what it had. Syncing never asks in the background for a scope the app did not declare: without events or contracts, the reads syncing makes of them come back empty. The prefix may not be decentrl or a scope name (identity, contracts, events, groups).

What the client expects from a transport

DecentrlTransport has required methods and optional capabilities; the client uses a capability when it's there and skips the feature when it isn't.

MethodRequiredUsed for
createIdentity, getIdentityyescreating and reading the identity (loadIdentity is optional)
publishEventyesstoring an event and delivering it to its recipient
processPendingEventsyesdraining events waiting on the own mediator (polling)
requestContract, getPendingContracts, acceptContract, declineContract, refreshContracts, getActiveContracts, revokeContractyescontacts
disposeyescleanup
queryEventsnoreading stored events: client.query, queryAndProcess, and the startup scan that rebuilds or resumes state
queryUnprocessedEvents, updateEventTagsnotagging received events with the app's tag templates
onEvents, onContractsChangednopush delivery instead of polling
processPreFetchedPendingEventsnoevents handed over by the WebSocket
publishPublicEvent, deletePublicEvent, fetchPublicEventsnopublic channels
groupsnoclient.groups, for transports that hold group keys themselves (the extension); see Groups

Rows returned by queryEvents and queryUnprocessedEvents should carry _mediatorEventId (the stored row's id, used to tag it) and _storedAt (when the mediator stored it, unix seconds). The client's snapshot resumes from the newest _storedAt it has read, so events are applied exactly once across reloads; without it the client still works, but rebuilds its state from every stored event on each load.

Groups

Groups (DCTRL-0006) are an optional capability: DecentrlTransport.groups, a GroupTransport. It works on raw envelopes: listing, joining, posting, reading pages, the admin operations, staff (staff, setGrant, and vouch for members the admin's device cannot account for; over the extension, GROUP_VOUCH), and letting this identity into groups it waits for (admitPending, run on every sync). The client adds the app's typing on top as client.groups and folds group events into the state. Without it, client.groups.isSupported is false.

Direct. For a DirectTransport (the default, or one you pass), the client builds the groups itself with createDirectGroups and attaches them before the first sync, since group keys arrive among the pending 1:1 events. With persist, group keys are kept in localStorage under ${key}:groups:${did} and administered groups under ${key}:group-admins:${did}. The app runs the admin's upkeep while it syncs.

To build them yourself, for a transport that wraps a DirectTransport, attach them before its first sync:

import { createDirectGroups, createLocalGroupStore, DirectTransport } from '@decentrl/sdk'

const direct = new DirectTransport()
const groups = createDirectGroups({
  transport: direct,
  store: createLocalGroupStore('my-app', localStorage),   // or createMemoryGroupStore()
  processPending: () => drainPendingEvents(),              // a newer group key may be waiting there
})

Extension. The extension holds group keys and admin states, runs the admin's upkeep from its own sync alarm, and watches the groups' hints; the app never sees a group key. ExtensionTransport.groups exists only when the app asks for the groups permission, so an app that doesn't use groups never sees a groups prompt:

const transport = await createExtensionTransport({
  extensionId: import.meta.env.VITE_EXTENSION_ID,
  appId: 'my-app',
  prefix: 'my-app',
  permissions: ['identity', 'contracts', 'events', 'groups', 'groups.admin'],
})

Each group belongs to one app (origin and appId): the one that created it, asked to join it, or accepted its invitation. Other apps of the same identity don't see it. Invitations are visible to every app with groups, and accepting one gives the group to that app. A post needs the scope of its event type too (my-app.*), and the extension signs it as the identity. Pages sent to the app hold only event types in its scopes. The extension's popup lists every group with the app it belongs to, and can decline invitations; accepting one happens in an app.

A test transport

For tests, a transport only needs the required methods; leave the optional ones out, or add those the test exercises:

import type { DecentrlTransport, SerializedIdentity } from '@decentrl/sdk'

const createTestTransport = (identity: SerializedIdentity): DecentrlTransport => {
  const published: unknown[] = []

  return {
    createIdentity: async () => identity,
    getIdentity: () => identity,
    publishEvent: async (envelope) => {
      published.push(envelope)
    },
    processPendingEvents: async () => [],
    requestContract: async () => {},
    getPendingContracts: async () => [],
    acceptContract: async () => {},
    declineContract: async () => {},
    refreshContracts: async () => [],
    getActiveContracts: () => [],
    revokeContract: async () => {},
    dispose: () => {},
  }
}

const client = chatApp.createClient({ transport: createTestTransport(testIdentity) })

publish still validates against your schemas, applies the event to local state and notifies onEvent listeners, so reducers and hooks can be tested without a mediator.