decentrl/docs
API Reference

@decentrl/sdk

Declarative SDK wrapping identity, crypto, event store, and transport into a single typed API.

npm install @decentrl/sdk zod

defineDecentrlApp(config)

Creates a typed Decentrl app configuration. All type parameters are inferred from the call.

import { defineDecentrlApp } from '@decentrl/sdk'

function defineDecentrlApp<TEvents, TState, TPublicEvents = {}, TChannels = {}>(config: {
  events: TEvents
  publicEvents?: TPublicEvents
  channels?: TChannels
  state: {
    [K in keyof TState]: {
      initial: TState[K]
      reduce: SliceReducers<TState[K], AppTypes<TEvents, TPublicEvents, TChannels, TState>>
    }
  }
}): DecentrlApp<AppTypes<TEvents, TPublicEvents, TChannels, TState>>

Returns: { config, createClient } — an inert configuration plus a factory method.

interface DecentrlApp<T extends AppTypes> {
  config: AppConfig<T>
  createClient(clientConfig: DecentrlClientConfig): DecentrlClient<T>
}

Event Definitions

interface EventDefinition<TSchema extends z.ZodType = z.ZodType> {
  schema: TSchema       // validated on publish and on every received event
  tags: string[]        // template strings like "chat.${chatId}"
  groupTags?: string[]  // group tag templates like "thread.${threadId}", evaluated on client.groups.publish (at most 8)
}
type EventDefinitions = Record<string, EventDefinition>

interface ChannelDefinition<TSchema extends z.ZodType = z.ZodType> {
  schema: TSchema
}
type ChannelDefinitions = Record<string, ChannelDefinition>

State Slices

Each slice's type is its initial value's; type it there (initial: [] as Chat[]). Reducers are keyed by event type, public:${type}, channel:${type} or group:deletion, and are fully inferred: an undeclared key is a compile error. The group: prefix is reserved for the SDK.

// One optional reducer per key:
//   'type'            (state: TSlice, data: z.output of events[type],       meta: EventMeta)       => TSlice
//   'public:type'     (state: TSlice, data: z.output of publicEvents[type], meta: PublicEventMeta) => TSlice
//   'channel:type'    (state: TSlice, data: z.output of channels[type],     meta: PublicEventMeta) => TSlice
//   'group:deletion'  (state: TSlice, data: GroupDeletionData,              meta: GroupDeletionMeta) => TSlice
type SliceReducers<TSlice, T extends AppTypes>

Group events use their event type's key, with meta.groupDid, meta.groupSeq and meta.groupTags set. They are folded once each, in the group's order, by a cursor per group kept in the state (and in the snapshot as groupCursors); a page that doesn't start at the cursor is refused. The own mediator's stored copies of group events (meta.groupDid set) are never folded as 1:1 events.

// A group event its author or the group's admin deleted: hide (senderDid, eventId)
interface GroupDeletionData {
  groupDid: string
  seq: number          // the deleted event's seq in the group
  senderDid: string
  eventId: string
  deletedBy: string    // its author, or the admin at the time
  noticeSeq: number    // the deletion notice's own seq
}

interface GroupDeletionMeta {
  groupDid: string
  senderDid: string    // who deleted it
}

App Types

Every type in the client and the React hooks derives from one AppTypes:

interface AppTypes<TEvents, TPublicEvents, TChannels, TState> {
  events: TEvents
  publicEvents: TPublicEvents
  channels: TChannels
  state: TState
}

// Get an app's AppTypes from its definition
type App = typeof app extends DecentrlApp<infer T> ? T : never
HelperWhat it is
StateOf<App>The state: { [slice]: type of its initial }
EventName<App>The private event type names
EventInput<App, K>What publish(K, …) takes: the schema's input (z.input)
EventOf<App, K?>A private envelope, discriminated by type; data is the schema's output
PublicEventName<App>The public event type names
PublicEventInput<App, K>What publishPublic(K, …) takes
PublicEventOf<App, K?>A verified public envelope, discriminated by type

ReturnType<typeof app.createClient> names the client type.


DecentrlClient

createClient(clientConfig)

Creates a client instance from an app definition. DecentrlClient<T> has one type parameter, the app's AppTypes.

const client = app.createClient({
  mediatorDid: 'did:web:mediator.decentrl.io',
  persist: { key: 'myapp', appVersion: '1' },   // optional localStorage persistence (identity, contract keys, state)
  transport: customTransport,                   // optional custom transport
})
interface DecentrlClientConfig {
  mediatorDid?: string
  persist?: PersistOptions
  transport?: DecentrlTransport
}

interface PersistOptions {
  key: string          // localStorage key prefix
  appVersion?: string  // bump when reducers change meaning: older snapshots are discarded
}

With persist, the client stores the identity under ${key}:identity and a state snapshot under ${key}:snapshot: the state, the keys of the private events it contains, and a cursor on the own mediator's storage time. On load the snapshot is used only if it belongs to the same identity, the same slice keys and the same appVersion; otherwise the state is rebuilt from the stored events. Startup reads stored events from 5 minutes below the cursor, and events the snapshot already contains are skipped, so each event is applied once. The legacy ${key}:state entry is removed.

The snapshot also holds a cursor per group (groupCursors). The default DirectTransport also keeps each contract's key state under ${key}:key-states and the ephemeral keys of unanswered requests under ${key}:ephemeral-keys. Key states are merged on write (read, merge, write), so tabs sharing the storage never roll each other back: for the same contract, the further-advanced state wins. Without persist, keys live only in memory, and after a reload every contract is unkeyed. With a direct transport, group member state is kept under ${key}:groups:${did} (merged across tabs like key states) and the admin states of administered groups under ${key}:group-admins:${did}. client.reset() removes both.

Publishing

client.publish<K extends EventName<App>>(
  eventType: K,
  data: EventInput<App, K>,
  options?: PublishOptions,
): Promise<{ eventId: string }>
interface PublishOptions {
  recipient?: string
  ephemeral?: boolean
  eventId?: string   // reuse the ID of a failed publish to retry it; a random UUID when omitted
}

Full type safety — the data parameter is the Zod schema's input for that event type. It is validated once (SCHEMA_VALIDATION_FAILED otherwise), and the parsed output is what is sent and what the local reducers get. A schema must accept its own output: defaults and stripped keys are fine, transforms are not.

The returned eventId is signed into the event. A failed publish throws PUBLISH_FAILED with details.eventId; publishing again with that eventId creates no second event (the recipient and your own mediator each keep one copy). Event IDs are 1–128 characters from A-Z a-z 0-9 . _ : -.

Public Events

client.publishPublic<K extends PublicEventName<App>>(
  eventType: K,
  data: PublicEventInput<App, K>,
  options?: PublicPublishOptions,
): Promise<{ publicEventId: string; eventId: string }>
client.deletePublicEvent(publicEventId: string, eventType?: string): Promise<void>
client.queryPublicEvents(options: PublicQueryOptions): Promise<VerifiedPublicEvents<PublicEventOf<App>>>
client.readPublicState(
  publisherDid: string,
  options?: { tags?: string[]; mediatorEndpoint?: string },
): Promise<StateOf<App>>
client.subscribeToChannel(config: ChannelSubscriptionConfig): { unsubscribe: () => void }
client.onPublicEvent(listener: (envelope: PublicEventOf<App>) => void): () => void
interface PublicQueryOptions {
  publisherDid: string
  mediatorEndpoint?: string
  tags?: string[]
  afterTimestamp?: number    // unix ms, as signed by the publisher
  beforeTimestamp?: number   // unix ms
  pagination?: { page: number; pageSize: number }
}

interface VerifiedPublicEvents<TEnvelope> {
  data: TEnvelope[]          // verified envelopes, newest first
  pagination: PaginationMeta // the mediator's paging, dropped events included
  dropped: DropCounts
}

type DropCounts = Record<'forged' | 'unverifiable' | 'unknownType' | 'invalid' | 'malformed', number>

queryPublicEvents accepts only events signed by the publisher, of a declared publicEvents type, whose data passes the schema (data is the schema's output); the rest are counted in dropped. A publisher whose DID yields no signing key gets nothing accepted (unverifiable). readPublicState folds the publisher's newest 500 verified public events, oldest first, through the app's public: reducers from initial; slices without public: reducers stay initial.

interface PublicPublishOptions {
  eventId?: string   // reuse to retry; the mediator returns the existing post
}

eventId is the signed event ID; publicEventId is the mediator's ID for the stored post, which deletePublicEvent takes. Publishing an eventId that was already published returns the existing post's publicEventId instead of creating a second one.

State

client.getState(): StateOf<App>
client.subscribe(listener: (state: StateOf<App>) => void): () => void

Querying

client.query(options?: QueryOptions): Promise<PaginatedResult<EventOf<App>>>
client.queryAndProcess(options?: QueryOptions): Promise<{ processed: number; total: number }>
client.onEvent(listener: (envelope: EventOf<App>) => void): () => void
interface QueryOptions {
  tags?: string[]
  participantDid?: string
  afterTimestamp?: number    // unix ms: when the own mediator stored the event
  beforeTimestamp?: number   // unix ms
  pagination?: { page: number; pageSize: number }
}

query returns only rows that are a declared type with valid data. queryAndProcess and onEvent see each event once: every private event reaching the reducers is validated against its schema (unknown types and invalid data are dropped and logged), applied once per (senderDid, eventId) — across reloads too, with persist — and a reducer that throws is logged and skipped.

Sync

client.startSync(options?: SyncOptions): void
client.stopSync(): void
client.sync(): Promise<void>
client.getConnectionStatus(): ConnectionStatus
client.onConnectionStatusChange(listener: (status) => void): () => void
interface SyncOptions {
  intervalMs?: number
  fallbackIntervalMs?: number
  onError?: (error: unknown) => void
  websocket?: boolean
}

type ConnectionStatus = 'disconnected' | 'connecting' | 'authenticating' | 'connected'

Lifecycle

client.reset(): void

IdentityManager

Accessible via client.identity.

client.identity.getIdentity(): IdentityState | null
client.identity.requireIdentity(): IdentityState
client.identity.create({ alias, mediatorDid }): Promise<IdentityState>
client.identity.load(serialized: SerializedIdentity): IdentityState
client.identity.serialize(): SerializedIdentity
client.identity.reset(): void
client.identity.onChange(listener): () => void

ContractManager

Accessible via client.contracts.

client.contracts.request(recipientDid): Promise<void>
client.contracts.getPending(): Promise<PendingContractRequest[]>   // group invitations left out
client.contracts.accept(pendingId, encryptedPayload, ephemeralPubKey): Promise<void>
client.contracts.decline(pendingId): Promise<void>
client.contracts.revoke(participantDid): Promise<void>
client.contracts.refresh(): Promise<void>
client.contracts.getActiveContracts(): StoredSignedContract[]   // status 'active' only
client.contracts.getUnkeyedContracts(): StoredSignedContract[]  // no keys on this device
client.contracts.getContractByDid(did): StoredSignedContract | undefined
client.contracts.onChange(listener): () => void

Contracts don't expire, so there is nothing to renew. revoke withdraws consent silently on your own mediator (REVOKE_COMMUNICATION_CONTRACT), forgets the contract's keys, and refreshes the list; the other side is not told. request to someone you already have a contract with makes a new contract that replaces the old one — the way to reconnect an unkeyed contract.


Groups

Groups implement DCTRL-0006. Apps use them through client.groups (GroupsApi), typed by the app's definition, with group events folded into the state through the app's reducers. The React hooks wrap it (@decentrl/sdk-react), and the browser extension provides it through ExtensionTransport.groups. Underneath, a transport's GroupTransport does the work: over a DirectTransport it is built from the low-level GroupMember and GroupAdmin, documented below for use on their own.

GroupsApi

Accessible via client.groups. Every method except isSupported, getGroups, onChange, refresh and onDeletion throws GROUPS_NOT_SUPPORTED when the transport has no groups.

client.groups.isSupported: boolean
client.groups.getGroups(): GroupSummary[]             // as last read; stable until they change
client.groups.onChange(listener: () => void): () => void
client.groups.refresh(): Promise<GroupSummary[]>

// Joining
client.groups.invitations(): Promise<GroupInvitationSummary[]>
client.groups.acceptInvitation(pendingId): Promise<void>
client.groups.declineInvitation(pendingId): Promise<void>
client.groups.create(options: CreateGroupOptions): Promise<{ groupDid: string }>
client.groups.requestToJoin(groupDid, options?: { note?: string }): Promise<void>
client.groups.cancelJoinRequest(groupDid): Promise<void>
client.groups.leave(groupDid): Promise<void>
client.groups.view(groupDid): Promise<GroupView>

// Events
client.groups.publish<K extends EventName<App>>(
  groupDid: string,
  eventType: K,
  data: EventInput<App, K>,
  options?: { eventId?: string },
): Promise<{ eventId: string; seq: number }>
client.groups.delete(groupDid, event: EventOf<App> | GroupEventRef): Promise<void>
client.groups.read(groupDid, options?: GroupReadOptions): Promise<GroupEventsPage<App>>
client.groups.onDeletion(listener: (deletion: GroupDeletionData) => void): () => void
client.groups.counts(groupDid, queries: GroupCountQuery[]): Promise<Record<string, number>>
client.groups.online(groupDid): Promise<string[]>
client.groups.caughtUp(groupDid): Promise<void>

// Admin and staff
client.groups.invite(groupDid, did): Promise<void>
client.groups.pendingJoinRequests(groupDid): Promise<GroupJoinRequestSummary[]>   // admin or approvers
client.groups.approve(groupDid, pendingId): Promise<void>
client.groups.decline(groupDid, pendingId): Promise<void>
client.groups.remove(groupDid, did): Promise<void>    // the admin: rotates at once; staff: at its next upkeep
client.groups.staff(groupDid): Promise<GroupStaff>
client.groups.setGrant(groupDid, did, plan: StaffGrantPlan): Promise<void>   // the admin only
client.groups.revokeGrant(groupDid, did): Promise<void>
client.groups.vouch(groupDid, did): Promise<void>   // the admin only (v0.6)
client.groups.rotate(groupDid): Promise<void>
client.groups.handOver(groupDid, did): Promise<void>
client.groups.acceptHandover(groupDid): Promise<void>
client.groups.deleteGroup(groupDid): Promise<void>
  • publish validates data against the event's schema (SCHEMA_VALIDATION_FAILED), evaluates its groupTags templates, sets the sender, posts, then reads the group: once it resolves the post is folded into the state. Nothing is applied optimistically. A post that hits STALE_GROUP_KEY drains the pending 1:1 events (where the newer key waits) and is retried once with the same eventId.
  • delete takes an event read from the group (meta.groupSeq must be set, GROUP_REFUSED otherwise) or a GroupEventRef, then reads the group back. Other members fold the deletion through group:deletion.
  • read returns one page, validated and typed (unknown types and invalid data left out), and folds nothing: for streams too large for the state. With tags it reads by group tag, forward by default; without, newest first.
  • onDeletion hears each deletion as it is folded, for views outside the state (paged reads).
  • counts asks the group's mediator how many live events match each query (DCTRL-0006 §7.24): a tag counts as a prefix, under every key version held; deleted events never count, and neither does anything this member may not read. Ids must be unique (INVALID_GROUP_COUNTS); a malformed tag throws INVALID_GROUP_TAGS. A count is the mediator's word, fit for display.
  • online answers the members subscribed to the group's hints on its mediator right now (§7.25).
  • caughtUp resolves once a read of the group started after the call has been folded: everything its mediator held then is in the state. For decisions a half-read state would get wrong, such as revoking grants.
  • staff answers the group's admin and grantees with their capabilities, from the grant log this member verified against what it pinned (GROUP_REFUSED when the mediator's log does not extend it). deletePrefixes is set where this device can read it: the admin sees every grantee's, a grantee its own. log is the mediator's record of recent approvals, declines and removals, for staff only.
  • setGrant replaces a member's capabilities: { delete?: 'all' | string[], remove?: true, approve?: true }; {} (or revokeGrant) takes them away. A malformed plan throws INVALID_GRANT_PLAN (or INVALID_GROUP_TAGS for bad prefixes) before anything is signed.
  • vouch (DCTRL-0006 v0.6 §11.7): the admin's device gives keys only to members it accounts for: its own adds, and members let in with an admission from an approver it trusts. Any other member the mediator lists is in staff.unaccounted and gets no new keys; vouch accounts for it, after the admin checked who it is, and delivers the keys it missed. remove is the other way out. GROUP_REFUSED if the DID is not a member.
  • notifyChanged tells onChange listeners a group changed where the list does not show it; staff actions and updated hints call it, so queries read again.
interface GroupStaff {
  adminDid: string
  incomingAdminDid?: string     // named by a pending handover: staff too until it ends
  members: GroupStaffMember[]   // { did, capabilities: GrantCapabilities, deletePrefixes? }
  log: GroupStaffAction[]       // { seq, actorDid, action: 'approve' | 'decline' | 'remove', targetDid }
  unaccounted?: string[]        // the admin's device only (v0.6): members it cannot account for, who get no new keys
}

// Whether a viewer may delete an event, as the mediator will judge it (author, admin, or a grantee whose delete covers it; never staff's events)
canDeleteGroupEvent({ staff, viewerDid, authorDid, groupTags }): boolean
  • acceptInvitation, create, requestToJoin, cancelJoinRequest, leave, approve, acceptHandover and deleteGroup refresh getGroups when they resolve.
interface CreateGroupOptions {
  alias: string
  history?: 'from_join' | 'full'   // fixed for good: 'full' lets members added later read from the start
  mediatorDid?: string             // where the group lives; the identity's own mediator by default
}

// A group this identity is in, or asked to join
interface GroupSummary {
  groupDid: string
  alias: string                     // from the group's DID
  role: 'admin' | 'member'          // admin when this device administers it
  status: 'joined' | 'requested'
  keyed: boolean                    // a key is held: it can be read and posted to
  handoverOffered: boolean          // the admin offered to hand it over to this identity
}

interface GroupInvitationSummary {
  pendingId: string
  groupDid: string
  adminDid: string
}

interface GroupJoinRequestSummary {
  pendingId: string
  requesterDid: string
  note?: string          // absent without a note, or when this member cannot open it
  hasNote: boolean
  createdAt: string      // when the request reached the mediator, ISO 8601
}

interface GroupCountQuery {
  id: string             // the count's key in the answer, 1 to 56 characters
  tag?: string           // a group tag, counted as a prefix
  senderDid?: string
  afterSeq?: number
}

interface GroupReadOptions {
  tags?: string[]                        // any of these group tags; without, newest first
  direction?: 'forward' | 'backward'     // with tags; forward by default
  cursor?: GroupReadCursor               // where the previous page stopped
  limit?: number
}

type GroupEventsPage<App> =
  | { status: 'ok'; events: EventOf<App>[]; deletions: GroupDeletion[]; cursor: GroupReadCursor; hasMore: boolean }
  | { status: 'removed' | 'deleted' }

// Where a group event sits, with its id. A deletion without the id reaches no reducer
type GroupEventRef = { seq: number; senderDid: string; eventId: string; groupTags?: string[] }

Only groups that are joined and keyed are followed: read on every sync tick and watched for hints (followedGroups(summaries) picks them). The admin's upkeep (GroupTransport.maintain: adding members who accepted, delivering keys, rotating) runs on its own 60-second interval while sync runs.

GroupTransport

The capability a transport provides as DecentrlTransport.groups, over raw envelopes. The extension transport has one when the app asks for the groups permission; for a DirectTransport the client builds one with createDirectGroups.

interface GroupTransport {
  list(): Promise<GroupSummary[]>
  onChange(listener: () => void): () => void   // joins, key arrivals, role changes, removal
  invitations(): Promise<GroupInvitationSummary[]>
  acceptInvitation(pendingId: string): Promise<void>
  declineInvitation(pendingId: string): Promise<void>
  create(options: CreateGroupOptions): Promise<{ groupDid: string }>
  requestToJoin(groupDid: string, options?: { note?: string }): Promise<void>
  cancelJoinRequest(groupDid: string): Promise<void>
  leave(groupDid: string): Promise<void>

  publish(groupDid: string, envelope: EventEnvelope, options?: { groupTags?: string[] }): Promise<{ eventId: string; seq: number }>
  deleteEvent(groupDid: string, target: GroupTargetEvent): Promise<void>
  sync(groupDid: string, afterSeq: number): Promise<GroupSyncResult>   // an unkeyed group reads as empty
  readTagged(groupDid: string, options: GroupTaggedReadOptions): Promise<GroupPageResult>
  readBackward(groupDid: string, options?: GroupBackwardReadOptions): Promise<GroupPageResult>
  view(groupDid: string): Promise<GroupView>
  counts(groupDid: string, queries: GroupCountQuery[]): Promise<Record<string, number>>
  online(groupDid: string): Promise<string[]>

  invite(groupDid: string, did: string): Promise<void>
  pendingJoinRequests(groupDid: string): Promise<GroupJoinRequestSummary[]>
  approve(groupDid: string, pendingId: string): Promise<void>
  decline(groupDid: string, pendingId: string): Promise<void>
  remove(groupDid: string, did: string): Promise<void>
  staff(groupDid: string): Promise<GroupStaff>
  setGrant(groupDid: string, did: string, plan: StaffGrantPlan): Promise<void>
  vouch(groupDid: string, did: string): Promise<void>
  rotate(groupDid: string): Promise<void>
  handOver(groupDid: string, did: string): Promise<void>
  acceptHandover(groupDid: string): Promise<void>
  deleteGroup(groupDid: string): Promise<void>

  maintain(): Promise<void>                              // the admin's upkeep, throttled per group
  admitPending(): Promise<void>                          // key parcels for groups waiting to let this identity in
  watch(onHint: (hint: GroupHint) => void): () => void   // hints for the followed groups
}

// Where to look; best effort, syncing does not depend on hints
interface GroupHint {
  groupDid: string
  kind: 'events' | 'updated' | 'gone'
  seq?: number   // events up to this seq, when known
}

followedGroups(summaries: GroupSummary[]): string[]   // joined and keyed

createDirectGroups

A GroupTransport over a DirectTransport, for whichever identity it holds. Create it before the transport's first sync: it registers the handler that receives group keys (RESERVED_HANDLER_TOO_LATE otherwise). The client does this for its own direct transport; the extension's service worker does it for its own.

createDirectGroups(options: DirectGroupsOptions): GroupTransport

interface DirectGroupsOptions {
  transport: DirectTransport
  store: GroupStore
  processPending: () => Promise<void>   // drains pending 1:1 events: a newer group key may be among them
  now?: () => number
}

const MAINTAIN_INTERVAL_MS = 60_000     // maintain() reconciles each admin group at most this often

publish and deleteEvent retry once after processPending when they hit STALE_GROUP_KEY. Admin states are restored on first use. A group found removed or deleted on sync is left and forgotten. summarizeGroups({ memberState, adminGroupDids, isRequestOpen }) is the listing it uses: withdrawn and lapsed requests left out.

Group Stores

Where direct groups keep each identity's member state and admin states. Loads are synchronous, so the member can be built before the first sync.

interface GroupStore {
  readonly ready?: Promise<void>   // an async store's cache is loaded; control events wait for it
  loadMember(did: string): GroupMemberState
  loadAdmins(did: string): Record<string, SerializedGroupAdminInput>
  saveMember(did: string, state: GroupMemberState): void | Promise<void>
  saveAdmin(did: string, groupDid: string, state: SerializedGroupAdmin | null): void | Promise<void>
  withLock?<T>(name: string, fn: () => Promise<T>): Promise<T>   // admin work on one group, alone across tabs
  onExternalChange?(listener: (did: string) => void): () => void // another tab changed the member state
}

createMemoryGroupStore(): GroupStore
createLocalGroupStore(prefix: string, storage: Storage): GroupStore
mergeMemberState(stored: GroupMemberState, next: GroupMemberState): GroupMemberState
memberStorageKey(prefix, did)   // `${prefix}:groups:${did}`
adminStorageKey(prefix, did)    // `${prefix}:group-admins:${did}`

createLocalGroupStore merges member state with what another tab wrote (mergeMemberState: this tab's groups win, and every key either holds is kept), takes a Web Lock per group for admin work when the browser has them, and hears other tabs through storage events. Admin states hold the group identities' private keys: store them like identity keys.

GroupMember

GroupMember and GroupAdmin are the low-level API that direct groups are built on. Used on their own, you drive a DirectTransport yourself and persist their state through callbacks.

import { DirectTransport, GroupAdmin, GroupMember } from '@decentrl/sdk'

One identity's side of every group it joined or asked to join. Create it right after the transport, before the transport's first sync: it registers the handler that receives group keys and handovers. Created later, it throws RESERVED_HANDLER_TOO_LATE.

const transport = new DirectTransport()
await transport.createIdentity({ alias: 'alice', mediatorDid })

const member = new GroupMember({
  transport,
  state: savedMemberState,             // optional GroupMemberState; state from before v0.3 loads as joined
  onStateChanged: (state) => save(state),
  now: Date.now,                        // optional clock for join-request expiry, in ms
})

If onStateChanged rejects, the key or handover that caused it stays pending and is applied again on the next transport.processPendingEvents().

// Joining
member.acceptInvitation(request: PendingContractRequest): Promise<void>
member.requestToJoin(groupDid, options?: { note?: string }): Promise<void>
member.cancelJoinRequest(groupDid): Promise<void>
member.joinStatus(groupDid): 'joined' | 'requested' | undefined

// Posting and deleting
member.publish(groupDid, envelope: EventEnvelope, options?: { tags?: string[]; groupTags?: string[]; eventId?: string })
  : Promise<{ eventId: string; seq: number }>
member.deleteEvent(groupDid, target: GroupTargetEvent): Promise<void>
member.deleteOwnEvents(groupDid): Promise<number>   // how many were deleted

// Reading
member.sync(groupDid, afterSeq: number): Promise<GroupSyncResult>
member.readBackward(groupDid, options?: { cursor?: GroupReadCursor; limit?: number }): Promise<GroupPageResult>
member.readTagged(groupDid, options: {
  tags: string[]
  direction?: 'forward' | 'backward'
  cursor?: GroupReadCursor
  limit?: number
}): Promise<GroupPageResult>
member.watch(options: GroupWatchOptions): () => void   // returns a stop function

// The group
member.query(groupDid): Promise<GroupView>
member.leave(groupDid): Promise<void>
member.groups(): string[]                             // joined only, not requested
member.chain(groupDid): AdminLink[]                   // the verified admin chain held; empty before the first key
member.certificates(groupDid): GroupKeyCertificate[]
member.pendingHandovers(): Array<{ groupDid: string; adminEpoch: number }>
member.getState(): GroupMemberState
  • Invitations arrive as contract requests with groupInvitation set (transport.getPendingContracts()). Join with acceptInvitation. It refuses a request without a verified invitation (GROUP_REFUSED). Accepting a group's request with transport.acceptContract instead makes a plain contact: that group's keys are then refused.
  • requestToJoin sends the group a contract request carrying a join request; note (at most 280 characters) is readable only by whoever runs the group. The group is then requested (joinStatus), and becomes joined when the group's first key arrives, that is, once its admin approved and reconciled. Calling it again while the request is pending does nothing, so the admin never sees it twice. A request not answered within 30 days lapses: joinStatus returns undefined, and if the group lets you in after that, the member withdraws: it revokes the contract and keeps the group as withdrawn, so the admin removes it on its next key delivery (a failed revoke leaves the group's key pending and is retried on the next sync). It throws ALREADY_A_MEMBER for a group already joined. cancelJoinRequest revokes the contract with the group and keeps the group as withdrawn; if revoking fails it throws and the request is still held. An approval that arrives anyway is refused the same way as after an expiry, and the group removes you on its next delivery. revokeContract(did, { keepPendingRequests: true }) is what keeps that approval readable.
  • Keys arrive over 1:1 channels, singly or in batches. Call transport.processPendingEvents() as usual. They are applied on the way, in the order the group sent them, and never returned as events.
  • publish posts under the newest key held, then stores your own copy tagged group.<groupDid>. groupTags (at most 8, INVALID_GROUP_TAGS otherwise) let members read the group by tag, for example a thread or a category; they are signed into the event and the group's mediator sees only a keyed hash of each. STALE_GROUP_KEY means a newer key is on its way: process pending events, then publish again with details.eventId. PUBLISH_FAILED with details.seq means the post went through but your own copy was not stored; retrying with details.eventId fixes it without a second post.
  • deleteEvent deletes an event: your own while you are a member, or anyone's if you are the admin. Pass the event's meta.groupSeq, meta.senderDid, meta.eventId and meta.groupTags (eventId may be left out when you cannot read the event; readers then hide nothing). Deleting an event that is already deleted succeeds. The mediator's refusals EVENT_NOT_FOUND, NOT_EVENT_AUTHOR and NOT_DELETABLE throw GROUP_REFUSED with the code in details.code. Copies members already stored are not erased.
  • deleteOwnEvents deletes every event of yours you can still read, newest first. Authors cannot delete once they have left, so call it before leave to take your posts along.
  • sync reads one page after afterSeq, stores each event on your mediator, and returns them with meta.groupDid, meta.groupSeq and meta.groupTags set, and the deletions among them that their signers were allowed to make: hide each (senderDid, eventId). Keep nextSeq as your cursor; hasMore: false means caught up, or waiting for a key. A new member starts from 0: in a group created with history: 'full' it reads the whole log, otherwise from when it was added.
  • readBackward reads newest first, for viewing older events: it stores nothing, never moves your sync cursor, and leaves out events whose key you never had. Pass the returned cursor to read further back; hasMore: false once there is nothing older you may read.
  • readTagged reads the events carrying any of tags (1 to 64 distinct, INVALID_GROUP_TAGS otherwise), oldest first or, with direction: 'backward', newest first. It has its own cursor, stores nothing, and never moves your sync cursor. Events whose signed tags match none of tags are dropped. A forward cursor can be passed back later to read what was posted since.
  • watch follows groups through their mediators' WebSocket hints: one socket per group mediator, logged in for that mediator only, subscribing in batches of 50 and again after every reconnect. Groups refused for the rate limit are subscribed again after 60 seconds. A group refused while you can still read it is logged as polling only (no onGone). A mediator's socket is closed once none of its groups are watched. Each hint is followed by a sync from the watcher's own cursor, so hints never replace your cursor. Keep polling as a fallback.
  • leave leaves, revokes your channel with the group, and forgets it. It is safe to retry.
type GroupSyncResult =
  | { status: 'ok'; events: EventEnvelope[]; deletions: GroupDeletion[]; nextSeq: number; hasMore: boolean }
  | { status: 'removed' }     // NOT_A_GROUP_MEMBER
  | { status: 'deleted' }     // GROUP_DELETED or GROUP_NOT_FOUND

type GroupPageResult =
  | { status: 'ok'; events: EventEnvelope[]; deletions: GroupDeletion[]; cursor: GroupReadCursor; hasMore: boolean }
  | { status: 'removed' }
  | { status: 'deleted' }

// Where readTagged or readBackward continues: past (forward) or below (backward) seq;
// readTagged also resumes from key version `version`
interface GroupReadCursor {
  seq: number
  version?: number
}

// A deletion to apply: hide the event (senderDid, eventId)
interface GroupDeletion {
  seq: number          // the deleted event's seq
  senderDid: string
  eventId: string
  deletedBy: string    // its author, or the group's admin at the time
  noticeSeq: number    // the deletion notice's own seq
}

interface GroupTargetEvent {
  seq: number          // meta.groupSeq
  senderDid: string    // meta.senderDid
  eventId?: string     // meta.eventId; omit when you cannot read the event
  groupTags?: string[] // meta.groupTags: readers by those tags see the deletion too
}

interface GroupWatchOptions {
  groupDids: string[]
  cursors?: Record<string, number>   // where each group's reading starts; 0 when absent
  onEvents?: (groupDid: string, page: { events: EventEnvelope[]; deletions: GroupDeletion[] }) => void
  onUpdated?: (groupDid: string) => void   // membership, keys or admin changed
  onGone?: (groupDid: string, status: 'removed' | 'deleted') => void   // no longer watched
}

interface GroupView {
  adminDid: string
  chain: AdminLink[]
  adminFromSeqs: (number | null)[]   // per link, the seq it took effect at; null where not recorded
  pendingLink: AdminLink | null
  members: string[]
  keyVersion: number
  rotationDue: boolean
  history: 'from_join' | 'full'      // as the mediator reports it; the genesis link is what counts
}

sync, readBackward and readTagged honour a deletion only when it is signed by the deleted event's author or by whoever was admin at that point of the group's log, and never hide anything by seq alone. watch calls onGone and stops watching a group when a read finds you removed or the group deleted; a login the mediator refuses stops watching that mediator's groups.

GroupAdmin

One device administering one group. It runs the group identity's own transport for the control channels and signs admin commands with the member's key. Operations run one at a time.

GroupAdmin.create({ member, alias, mediatorDid, history?, onStateChanged?, now? }): Promise<GroupAdmin>
GroupAdmin.fromState(state: SerializedGroupAdminInput, { member, onStateChanged?, now? }): Promise<GroupAdmin>
GroupAdmin.acceptHandover({ groupDid, member, onStateChanged?, now? }): Promise<GroupAdmin>

admin.groupDid: string
admin.invite(did): Promise<void>
admin.setGrant(did, plan: StaffGrantPlan): Promise<void>
admin.revokeGrant(did): Promise<void>
admin.vouch(did): Promise<string[]>     // the members delivered to
admin.reconcile(): Promise<ReconcileSummary>
admin.rotate(): Promise<number>          // the new key version
admin.remove(did): Promise<number>       // removes and rotates in one step
admin.redeliverKeys(did, fromVersion?: number): Promise<string[]>   // the members delivered to
admin.handOver(did): Promise<void>
admin.delete(): Promise<void>
admin.getState(): SerializedGroupAdmin
  • create registers a new group identity on mediatorDid (alias is the group's name), opens its channel to you, creates the group, and issues key v1. history ('from_join' by default, or 'full') is fixed for good: in a full group members added later read everything from the start and receive every key from v1.
  • invite sends the invitation. Nothing else happens until the invitee accepts and you call reconcile. An invitation not accepted within 30 days expires.
  • Join requests go to the group's mediator (DCTRL-0006 v0.4); the admin and approvers answer them with listJoinRequests, approveJoinRequest and declineJoinRequest (or client.groups).
  • setGrant signs a staff grant and writes the delete scopes it needs for every key version held; revokeGrant takes the capabilities away.
  • vouch puts a listed member the admin could not account for on its roster and delivers the keys it missed (v0.6). reconcile reports such members as unaccounted and never removes them.
  • reconcile adds invitees whose channel is active, rotates when due (after someone leaves or is removed, or after a handover), delivers keys members still need (as parcels to members without a channel), revokes channels of people who left, forgets invitations that expired, dismisses every contract request to the group, publishes the join key and the delete scopes the current key version needs, and finishes a rotation a crash interrupted. Call it when you start (after fromState) and whenever you want invitees added. It returns status: 'handed-over' or 'deleted' once this device no longer administers the group.
  • Key delivery sends each member the keys it is owed in ascending batches of up to 50. A member whose delivery fails part-way resumes from the failed batch on the next reconcile. redeliverKeys delivers a member's keys again from fromVersion (by default, all it is owed: from v1 in a full group).
  • handOver first delivers every pending key, and throws KEYS_UNDELIVERED if the member's could not be delivered. It then sends the member the admin state and names it on the mediator. You stay admin until it accepts. The successor calls GroupAdmin.acceptHandover (see member.pendingHandovers()); that accepts, adopts the state, and reconciles, which rotates the key. NO_MATCHING_HANDOVER means no handover bundle it holds matches the mediator's pending link. In a full group the successor refuses a bundle unless, with the keys it holds, it covers every version from v1: deliver its keys again with redeliverKeys, then hand over again.
  • delete deletes the group's events and members on its mediator. The group cannot be created again.

onStateChanged receives the SerializedGroupAdmin, which holds the group identity's private keys: store it like identity keys. It receives null once the group is handed over or deleted; erase the stored state then. After that every call throws GROUP_HANDED_OVER. fromState also takes state written before v0.3, whose invites are bare DIDs: they load as invited at the time of loading, by the admin's now clock (as do bare-DID invites in a handover bundle from such an admin). Joins and key deliveries only happen while an admin device is running.

interface ReconcileSummary {
  status: 'admin' | 'handed-over' | 'deleted'
  added: string[]
  rotatedTo?: number
  delivered: string[]
  revoked: string[]
  reinvited: string[]
  left: string[]
  expired: string[]     // invitations never accepted within 30 days, forgotten
  dismissed: number     // contract requests to the group that were not join requests
}

type GroupInvite = { did: string; at: number }   // SerializedGroupAdmin.invites; at is ms

DirectTransport Additions

transport.setReservedEventHandler(handler: ReservedEventHandler): void
transport.requestContract(recipientDid, options?: {
  signInvitation?: (contract) => GroupInvitation
  joinRequest?: { note?: string }   // never together with signInvitation
}): Promise<void>

GroupMember and GroupAdmin call these for you. setReservedEventHandler throws RESERVED_HANDLER_TOO_LATE after the transport's first sync. Without a handler, reserved decentrl.* events are acknowledged and dropped.


Types

Event Types

interface EventMeta {
  senderDid: string   // authenticated
  timestamp: number   // unix ms, as claimed by the sender
  eventId: string     // authenticated: signed inside the envelope
  ephemeral?: boolean
  groupDid?: string   // authenticated; set on group events read from that group, never on 1:1 events
  groupSeq?: number   // group events: the event's seq on the group's mediator, what deleteEvent takes
  groupTags?: string[] // group events: the sender's signed group tags, when it set any
}

interface EventEnvelope<TType extends string = string, TData = unknown> {
  type: TType
  data: TData
  meta: EventMeta
  tags?: string[]
  _mediatorEventId?: string   // the stored row's ID on your mediator (not meta.eventId)
  _storedAt?: number          // when your mediator stored it (its clock, unix seconds)
}

On received events, meta.senderDid is the authenticated sender and meta.eventId the ID signed in the envelope, whatever the sender claimed. A meta.groupDid claimed on a 1:1 event is removed. meta.timestamp is not authenticated, and neither is anything in data: take identity from meta, never from payload fields like sender or createdBy. The client processes each (senderDid, eventId) once, ephemeral events included.

Public Event Types

interface PublicEventMeta {
  publisherDid: string
  timestamp: number        // unix ms, as signed by the publisher
  eventId: string          // the publisher's signed event ID
  publicEventId: string    // the mediator's ID for the post: what deletePublicEvent takes
}

interface PublicEventEnvelope<TType extends string = string, TData = unknown> {
  type: TType
  data: TData
  meta: PublicEventMeta
  tags?: string[]
}

// A public event as the mediator serves it: unverified, timestamp in unix seconds.
// The client verifies these into envelopes; you rarely see one.
interface PublicEventResult {
  id: string               // the mediator's ID for the post
  eventId: string | null   // the signed event ID; null only on posts from before event IDs
  event: string
  tags: string[]
  timestamp: number
  eventSignature: string
}

Public events are processed once per (publisherDid, eventId). A post is accepted only when its signature verifies over { event, tags, timestamp, event_id } against the publisher's key, its type is declared, and its data passes the schema. Everything else — including every post without an eventId, and every post of a publisher whose DID yields no signing key — is dropped, in queryPublicEvents, readPublicState and channel subscriptions alike.

Identity Types

interface IdentityState {
  did: string
  alias: string
  mediatorDid: string
  mediatorEndpoint: string
  keys: DecentrlIdentityKeys
  mediatorContract: SignedCommunicationContract | null
}

interface SerializedIdentity {
  did: string
  alias: string
  mediatorDid: string
  mediatorEndpoint: string
  keys: { signing: SerializedKeyPair; encryption: SerializedKeyPair; storageKey: string }
  mediatorContract: SignedCommunicationContract | null
}

Contract Types

interface StoredSignedContract {
  id: string
  participantDid: string
  participantAlias?: string
  signedCommunicationContract: SignedCommunicationContract
  createdAt: number
  status: 'active' | 'unkeyed'   // unkeyed: this device holds no keys; request again to reconnect
}

interface PendingContractRequest {
  id: string
  senderDid: string
  encryptedPayload: string
  requestorEphemeralPublicKey: string
  groupInvitation?: { groupDid: string; adminDid: string }   // a verified group invitation
  joinRequest?: { note?: string }                              // someone asks to join the group this identity is
}

Error Handling

class DecentrlSDKError extends Error {
  code: DecentrlSDKErrorCode
  details?: unknown
}

type DecentrlSDKErrorCode =
  | 'IDENTITY_NOT_INITIALIZED'
  | 'IDENTITY_ALREADY_EXISTS'
  | 'CONTRACT_NOT_FOUND'
  | 'SCHEMA_VALIDATION_FAILED'
  | 'UNKNOWN_EVENT_TYPE'
  | 'SYNC_FAILED'
  | 'PUBLISH_FAILED'
  | 'QUERY_FAILED'
  | 'QUERY_NOT_SUPPORTED'
  | 'MEDIATOR_ERROR'
  | 'RECIPIENT_MEDIATOR_NOT_FOUND'
  | 'NO_TRANSPORT'
  | 'RESERVED_HANDLER_TOO_LATE'   // GroupMember attached after the transport's first sync
  | 'GROUP_UNKEYED'               // no key for the group yet
  | 'STALE_GROUP_KEY'             // a newer key is on its way; sync, then retry with details.eventId
  | 'GROUP_REMOVED'               // you are not a member (any more)
  | 'GROUP_DELETED'               // the group was deleted or is unknown to its mediator
  | 'GROUP_REFUSED'               // any other refusal; details.code has the mediator's code
  | 'NO_MATCHING_HANDOVER'        // no held handover matches the pending link
  | 'GROUP_HANDED_OVER'           // this device no longer administers the group
  | 'INVALID_GROUP_TAGS'          // more than 8 group tags on a post, or not 1 to 64 tags to read by
  | 'ALREADY_A_MEMBER'            // requestToJoin for a group already joined
  | 'KEYS_UNDELIVERED'            // handOver: keys owed to the successor could not be delivered
  | 'GROUPS_NOT_SUPPORTED'        // client.groups on a transport without groups

Transport

The DecentrlTransport interface is pluggable. The default DirectTransport uses HTTP + WebSocket. Its optional groups?: GroupTransport provides client.groups; the client builds one for a DirectTransport itself. See Transport Layer for custom implementations.