@decentrl/sdk
Declarative SDK wrapping identity, crypto, event store, and transport into a single typed API.
npm install @decentrl/sdk zoddefineDecentrlApp(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| Helper | What 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): () => voidinterface 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): () => voidQuerying
client.query(options?: QueryOptions): Promise<PaginatedResult<EventOf<App>>>
client.queryAndProcess(options?: QueryOptions): Promise<{ processed: number; total: number }>
client.onEvent(listener: (envelope: EventOf<App>) => void): () => voidinterface 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): () => voidinterface SyncOptions {
intervalMs?: number
fallbackIntervalMs?: number
onError?: (error: unknown) => void
websocket?: boolean
}
type ConnectionStatus = 'disconnected' | 'connecting' | 'authenticating' | 'connected'Lifecycle
client.reset(): voidIdentityManager
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): () => voidContractManager
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): () => voidContracts 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>publishvalidatesdataagainst the event's schema (SCHEMA_VALIDATION_FAILED), evaluates itsgroupTagstemplates, 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 hitsSTALE_GROUP_KEYdrains the pending 1:1 events (where the newer key waits) and is retried once with the sameeventId.deletetakes an event read from the group (meta.groupSeqmust be set,GROUP_REFUSEDotherwise) or aGroupEventRef, then reads the group back. Other members fold the deletion throughgroup:deletion.readreturns one page, validated and typed (unknown types and invalid data left out), and folds nothing: for streams too large for the state. Withtagsit reads by group tag,forwardby default; without, newest first.onDeletionhears each deletion as it is folded, for views outside the state (paged reads).countsasks the group's mediator how many live events match each query (DCTRL-0006 §7.24): atagcounts 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 throwsINVALID_GROUP_TAGS. A count is the mediator's word, fit for display.onlineanswers the members subscribed to the group's hints on its mediator right now (§7.25).caughtUpresolves 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.staffanswers the group's admin and grantees with their capabilities, from the grant log this member verified against what it pinned (GROUP_REFUSEDwhen the mediator's log does not extend it).deletePrefixesis set where this device can read it: the admin sees every grantee's, a grantee its own.logis the mediator's record of recent approvals, declines and removals, for staff only.setGrantreplaces a member's capabilities:{ delete?: 'all' | string[], remove?: true, approve?: true };{}(orrevokeGrant) takes them away. A malformed plan throwsINVALID_GRANT_PLAN(orINVALID_GROUP_TAGSfor 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 instaff.unaccountedand gets no new keys;vouchaccounts for it, after the admin checked who it is, and delivers the keys it missed.removeis the other way out.GROUP_REFUSEDif the DID is not a member.notifyChangedtellsonChangelisteners a group changed where the list does not show it; staff actions andupdatedhints 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 }): booleanacceptInvitation,create,requestToJoin,cancelJoinRequest,leave,approve,acceptHandoveranddeleteGrouprefreshgetGroupswhen 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 keyedcreateDirectGroups
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 oftenpublish 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
groupInvitationset (transport.getPendingContracts()). Join withacceptInvitation. It refuses a request without a verified invitation (GROUP_REFUSED). Accepting a group's request withtransport.acceptContractinstead makes a plain contact: that group's keys are then refused. requestToJoinsends 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 thenrequested(joinStatus), and becomesjoinedwhen 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:joinStatusreturnsundefined, and if the group lets you in after that, the member withdraws: it revokes the contract and keeps the group aswithdrawn, 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 throwsALREADY_A_MEMBERfor a group already joined.cancelJoinRequestrevokes the contract with the group and keeps the group aswithdrawn; 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. publishposts under the newest key held, then stores your own copy taggedgroup.<groupDid>.groupTags(at most 8,INVALID_GROUP_TAGSotherwise) 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_KEYmeans a newer key is on its way: process pending events, then publish again withdetails.eventId.PUBLISH_FAILEDwithdetails.seqmeans the post went through but your own copy was not stored; retrying withdetails.eventIdfixes it without a second post.deleteEventdeletes an event: your own while you are a member, or anyone's if you are the admin. Pass the event'smeta.groupSeq,meta.senderDid,meta.eventIdandmeta.groupTags(eventIdmay be left out when you cannot read the event; readers then hide nothing). Deleting an event that is already deleted succeeds. The mediator's refusalsEVENT_NOT_FOUND,NOT_EVENT_AUTHORandNOT_DELETABLEthrowGROUP_REFUSEDwith the code indetails.code. Copies members already stored are not erased.deleteOwnEventsdeletes every event of yours you can still read, newest first. Authors cannot delete once they have left, so call it beforeleaveto take your posts along.syncreads one page afterafterSeq, stores each event on your mediator, and returns them withmeta.groupDid,meta.groupSeqandmeta.groupTagsset, and the deletions among them that their signers were allowed to make: hide each(senderDid, eventId). KeepnextSeqas your cursor;hasMore: falsemeans caught up, or waiting for a key. A new member starts from0: in a group created withhistory: 'full'it reads the whole log, otherwise from when it was added.readBackwardreads newest first, for viewing older events: it stores nothing, never moves yoursynccursor, and leaves out events whose key you never had. Pass the returnedcursorto read further back;hasMore: falseonce there is nothing older you may read.readTaggedreads the events carrying any oftags(1 to 64 distinct,INVALID_GROUP_TAGSotherwise), oldest first or, withdirection: 'backward', newest first. It has its owncursor, stores nothing, and never moves yoursynccursor. Events whose signed tags match none oftagsare dropped. A forward cursor can be passed back later to read what was posted since.watchfollows 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 (noonGone). A mediator's socket is closed once none of its groups are watched. Each hint is followed by asyncfrom the watcher's own cursor, so hints never replace your cursor. Keep polling as a fallback.leaveleaves, 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(): SerializedGroupAdmincreateregisters a new group identity onmediatorDid(aliasis 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 afullgroup members added later read everything from the start and receive every key from v1.invitesends the invitation. Nothing else happens until the invitee accepts and you callreconcile. 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,approveJoinRequestanddeclineJoinRequest(orclient.groups). setGrantsigns a staff grant and writes the delete scopes it needs for every key version held;revokeGranttakes the capabilities away.vouchputs a listed member the admin could not account for on its roster and delivers the keys it missed (v0.6).reconcilereports such members asunaccountedand never removes them.reconcileadds 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 (afterfromState) and whenever you want invitees added. It returnsstatus: '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.redeliverKeysdelivers a member's keys again fromfromVersion(by default, all it is owed: from v1 in afullgroup). handOverfirst delivers every pending key, and throwsKEYS_UNDELIVEREDif 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 callsGroupAdmin.acceptHandover(seemember.pendingHandovers()); that accepts, adopts the state, and reconciles, which rotates the key.NO_MATCHING_HANDOVERmeans no handover bundle it holds matches the mediator's pending link. In afullgroup the successor refuses a bundle unless, with the keys it holds, it covers every version from v1: deliver its keys again withredeliverKeys, then hand over again.deletedeletes 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 msDirectTransport 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 groupsTransport
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.