Integrating trueseal-sync

This guide is the mental model for integrating trueseal-sync into an application — what you wire, what you listen to, what you decide. It is intentionally SDK-agnostic: every SDK exposes the same shape, but adapts the names to its language.

info

Code references use a generic camelCase convention (session.publish(), onMemberJoined). Each SDK adapts these to its language’s idioms — see the SDK reference for exact signatures.

TL;DR

  • A session is the long-lived object. Init once at app start, keep for app lifetime.
  • Streams come out, calls go in. Listen to connection state, members, blobs, pairing requests; call publish, removeMember, destroyGroup.
  • Pairing is a three-step dance. Generate a token, share out-of-band, accept the request.
  • trueseal guarantees delivery and ordering. It does not resolve conflicts, backfill history, or model identity — those are yours.
  • Two terminal events. onRemovedFromGroup and onGroupDestroyed mean wipe + reinit.

The Pieces You Wire

trueseal-sync gives you:

  • A stable device identity (X25519 + Ed25519 keypair, persisted locally).
  • Encrypted delivery of arbitrary binary blobs to all group members via the relay.
  • A pairing ceremony to add devices to a group.
  • Outbox replay — messages sent while offline are delivered when the relay reconnects.
  • Deterministic, human-readable device names derived from public keys.

It does not give you:

  • Knowledge of whether a peer device is online.
  • Message history (the relay is a delivery buffer, not a log).
  • Multi-group management in one session — see Multiple groups.
  • A “leave quietly” protocol message — see Revocation in Practice.
  • Conflict resolution, payload ordering beyond FIFO per sender, or deduplication of self-echoes.

The shape of an integration: construct a session → listen to its event streams → call methods on it → handle terminal events.

Device Identity

Every session has a permanent identity, generated locally on first run and persisted to disk. It survives restarts. It is destroyed only by destroyGroup() or by deleting the storage directory. For the protocol-level model, see Device Identity.

For integration purposes, three values matter:

  • session.localDeviceName — a stable, human-readable name (e.g. FreeMap, SwiftHorizon). Display it as “this device”.
  • session.localNodeId — a stable opaque identifier. Use it as the key in membership maps and the target argument for removeMember.
  • session.members() — the current member list. It excludes the local device — you will never see yourself in this list. Display the local device separately.

Do not re-derive the name or ID in your app — use what the SDK exposes so all clients stay in sync.

Pointing at a Relay

The SDK takes two values at construction time: a relay URL and the relay’s public key (32 bytes). Get both from whoever runs the relay — yourself, a friend, or the public hosted relay.

The public key is a build-time constant. Bake it into your client; do not make it user-configurable. The Noise XX handshake verifies you are talking to the correct relay before any data is exchanged.

Relay offline is a no-op. If the relay is unreachable at launch or drops mid-session, the SDK queues outbound messages in the local outbox and reconnects automatically. Write no reconnect logic. Show a connection indicator and do nothing else.

For the operator-side view — deploying a relay, generating its keypair, configuring TTL — see Deploying.

Session Lifecycle

Initialisation

Construct one session per app lifecycle. Initialisation reads or generates the keypair from storage and begins connecting in the background. It returns immediately — it does not block on relay connectivity.

A throw at init time is a developer or deployment error — bad arguments, corrupt storage, missing entitlements. Treat it as fatal. There is no meaningful recovery path.

The session is a singleton

Instantiate once at app startup and keep for the app’s lifetime. There is no meaningful “restart session” other than destroyGroup() followed by reinit.

Startup sequence

On every boot, before the relay connection is established:

  1. Seed your member list from session.members() — devices already in the group from the previous run.
  2. Start listening to member events.
  3. Start listening to connection state.
  4. Start listening to incoming blobs.
  5. Start listening to pairing requests (if the app offers pairing).

Seed first — never show an empty member list when you already have members.

Multiple groups via namespaces

A device can participate in more than one group simultaneously by running multiple sessions with different namespaces. Each session is fully independent — separate keypair, separate storage, separate relay connection, separate manifest.

sessionA = Session(namespace: "work",     storage: .../work/)
sessionB = Session(namespace: "personal", storage: .../personal/)

The SDK does not manage session switching — that is the caller’s responsibility. If your app offers a group switcher, maintain an array of sessions and route publishes and incoming blobs to whichever is active.

Namespace strings are arbitrary. Use something collision-resistant if your app ships to end users who might run multiple copies — e.g. include your app bundle ID as a prefix.

The Pairing Dance

For the protocol-level picture, see Pairing and the Architecture flow diagram. This section is the integrator’s view.

Two roles

RoleAction
Host (A)Generates and shares the token, accepts incoming requests.
Joiner (B)Receives the token out-of-band, calls session.join(token).

In a macOS + iOS scenario where macOS shows a QR and iOS scans it: macOS is A, iOS is B. The relationship is symmetric — either side can be host or joiner.

The token

The pairing token is an opaque string containing the device’s permanent public keys. It is stable for the lifetime of the keypair. Generate it once, cache it, and display it freely — showing it in a QR code, as text, or copying it to the clipboard has no side effects.

The acceptance window

The window is caller-controlled — there is no protocol-level timer or auto-expiry.

  • session.pairingToken() opens the window immediately.
  • The window stays open until acceptRequest() is called (single-use) or the caller calls session.cancelPairing().
  • Call cancelPairing() when the user dismisses the pairing UI.

Design guidance:

  • Open on demand. Show an explicit OPEN / CLOSE control. Never auto-open without user intent.
  • No timer auto-close. Auto-expiry causes silent drops where the joiner knocks but the host’s window closes before they can respond. Let the user decide when to stop accepting.
  • Single-use. Once a request is accepted, call cancelPairing() immediately. The next pairing requires a fresh token call.

What you handle

trueseal-sync handles the cryptography, the relay communication, and the manifest update. You handle:

  • Presenting the token — encoding it as a QR code, sharing via AirDrop, copying to a text field.
  • Deciding when to acceptonMemberRequest fires with a Member Request Token. You decide whether to present a confirmation UI or accept automatically.
  • Bootstrapping the new member’s history — see Bootstrapping a New Member below.

Publishing and Receiving

Publishing

session.publish(blob)   // raw bytes
session.publish(text)   // convenience: UTF-8 encode then publish

Publishing is fire-and-forget. If the relay is offline the message queues in the outbox and delivers automatically on reconnection. There is no per-message delivery confirmation exposed to the caller.

Receiving

Incoming blobs arrive as events containing:

  • data — the raw payload bytes.
  • senderNoisePub — the sender’s X25519 public key (32 bytes).

The blob stream may fire for your own messages depending on relay implementation. Implement deduplication at the app layer — content hash against local storage — rather than relying on the transport to filter self-sent messages.

Sync semantics: broadcast-each

The recommended pattern for single-value streams (clipboard, presence, settings):

  • On every new item, broadcast the full item immediately.
  • Do not diff — broadcast complete payloads.
  • Dedup at the receiver — if the content already exists in local storage, drop it.
  • Outbox replay handles late or out-of-order delivery.

This is simpler and more robust than delta-sync or last-write-wins schemes when payloads are small.

Handling Conflicts

trueseal-sync guarantees that every device receives every blob, in arrival order, eventually. It does not guarantee convergence on a shared state when two devices write conflicting data while offline.

Conflict resolution is yours. Common patterns:

Append-only log. No conflicts possible. Each device’s writes are independent records. The application reads the union. Works for chat, event feeds, audit logs.

Last-write-wins on a logical clock. Embed a timestamp (or Lamport clock) in the payload. On receive, if the incoming version is newer than what you have, replace; otherwise discard. Works for single-value sync — preferences, current selection, latest snapshot.

CRDTs. Use a CRDT library on top of trueseal — Yjs, Automerge, etc. Send CRDT updates as opaque blobs. Each peer applies updates in arrival order; the CRDT guarantees convergence regardless of order.

Application-defined merge. Your data has a domain-specific merge function. Send updates; merge on receive.

trueseal does not pick a winner. It does not have an opinion about your data model. Delivery and ordering are the contract.

Bootstrapping a New Member

When a device joins the group via pairing, it has the manifest but no history. The relay only forwards blobs sent after a device joined — it does not retroactively replay past blobs to new members.

Catching up the new member is the application’s job. Listen for onMemberJoined and decide what to send. Patterns:

Send everything. Cheap if data is small (a few hundred items). Iterate your local store, publish each item. The new member dedups on receipt.

Send a snapshot. One consolidated blob containing the current state. Smaller than full history if you keep many small writes. The new member loads the snapshot, then catches up on subsequent live writes.

Send nothing. For apps where late-joining means starting from now — clipboard sync, presence, telemetry. The new device sees only what is sent after it joined.

Trade-offs:

PatternBest whenCost
EverythingSmall datasets, every record mattersOne push per record
SnapshotLong-lived state, many small writesCaller maintains a snapshot
NothingStream-of-now appsNone — but late-joiners miss past data

The choice is yours. trueseal does not impose one.

Member Management

The manifest

session.members() returns a snapshot of current group membership. It excludes the local device — you will never see yourself in this list.

On every member event, re-snapshot from session.members() rather than maintaining a local delta. This avoids missed events from race conditions.

Member events

EventMeaning
onMemberJoined(id, name)A new device joined the group.
onMemberLeft(id, name)A member was removed.
onRemovedFromGroupYou were removed by another member.
onGroupDestroyedThe group was destroyed — see Revocation in Practice.

onRemovedFromGroup and onGroupDestroyed are terminal — the session is no longer usable for the current group. Wipe and reinit.

Removing a member

session.removeMember(memberId)

The removed device receives an onRemovedFromGroup event. The removal is immediately reflected in session.members() on the calling device.

Connection vs Group State

Model these as two independent signals. Never conflate them.

SignalSourceUI
Relay connectivityConnection state streamRELAY: CONNECTED / RELAY: OFFLINE
Group membershipmembers() countGROUP: SOLO / GROUP: N DEVICES

RELAY: CONNECTED + GROUP: SOLO is a valid steady state — the app is working, just not paired with anyone yet.

RELAY: OFFLINE + GROUP: N DEVICES is also valid — paired, relay temporarily unreachable, outbox will replay on reconnection.

Never show “you cannot sync” based on relay state alone. The relay being offline is transient. The group relationship is persistent.

Revocation in Practice

For the protocol-level model, see Revocation. This is the integrator’s view.

trueseal exposes two operations. They are not interchangeable — they have different costs and different threat models.

Soft removal

Routine: a teammate left, a phone was upgraded, a laptop was decommissioned.

session.removeMember(memberId)

That is the entire integration. The new manifest propagates; remaining members start filtering the removed device’s blobs. Cooperative, not cryptographic — fine for routine use.

Destroy group

Security incident: a device was stolen, a key was compromised, you no longer trust a member.

session.destroyGroup()

When onGroupDestroyed fires on any device:

  1. Stop all stream listeners.
  2. Delete the storage directory.
  3. Reinitialise the session — fresh keypair, new identity, solo mode.
  4. Communicate out-of-band with the legitimate members and re-pair.

Every member must re-pair. This is intentional — the price of cryptographic exclusion.

Leave quietly (no protocol primitive)

There is no “leave quietly” operation that removes you without notifying the group. To leave:

  1. Delete local storage and reinitialise — this rotates your keypair.
  2. Your old node ID becomes a ghost member in other devices’ manifests.
  3. Other devices must manually remove the ghost entry.

A graceful leave protocol may be added in a future trueseal-sync version.

Storage Scoping

Never use the SDK’s default storage path — it may be shared across all trueseal-sync consumers on the same machine. Two apps sharing a storage path would share a group identity.

Recommended pattern:

<platform app support dir> / <your-app-bundle-id> / TrueSealSync /

Create the directory before passing it to the session constructor.

You’ll Know It Works When…

  • Pairing succeeds. A second device shows up in session.members() after onMemberJoined fires on both sides.
  • Blobs flow both directions. A publish from device A produces an event in device B’s blob stream, and vice versa.
  • Outbox drains on reconnect. Publish while the relay is offline, reconnect, watch the queued messages arrive on the recipient.

If those three work, the integration is sound.