Search selfstore
v1.8.21

API: groups and households

The two entry points behind sharing between people (as opposed to a user’s own devices). The model and its threat analysis are on peers and groups; this page is the surface.

Most apps never import these directly. They surface through the share and join widgets, which drive a ShareEngine and a JoinEngine your app builds - often on top of what is here.

selfstore/groups

Passwordless groups: each member holds a keypair, an admin signs a manifest, and the backup’s envelope carries one key slot per member.

Experimental, and the only entry that is. These exports may change shape, or be withdrawn, in a minor release; every other subpath waits for a major. The reason is evidence rather than doubt about the cryptography: no application has shipped this API, so its shape has never been tested by a second pair of hands, and it is the most security-sensitive surface in the package. The file is not experimental - group mode is format generation 2, specified with a canonical test vector, and keeps the guarantee every backup gets. What is at risk is a recompile, never anyone’s data. The shared-passphrase mode is unaffected.

Export Signature Notes
generateIdentity () => Promise<GroupIdentity> A fresh member keypair
publicIdentity (identity) => ... The shareable half
keyId (...) => string Stable id for a key
newGroupId () => string
signManifest (...) => Promise<SignedManifest> Admin-side
openManifest (...) => Promise<GroupManifest> Verifies before returning
groupCryptoAvailable () => boolean Feature detection
GROUP_KEYING constant The keying scheme identifier
identityVault (kv: KV) => IdentityVault Where a member’s private key lives

IdentityVault

interface IdentityVault {
  load(): Promise<GroupIdentity | null>;
  save(identity: GroupIdentity): Promise<void>;
  loadOrCreate(): Promise<GroupIdentity>;
  clear(): Promise<void>;
  isProtected(): Promise<boolean>;
  unlock(passphrase: string): Promise<GroupIdentity>;
  protect(passphrase: string): Promise<void>;
  unprotect(passphrase: string): Promise<void>;
}

loadOrCreate() is the call an app makes at boot. protect() puts the private key behind a passphrase, so a copied browser profile does not carry group membership with it.

The store verifies manifests itself - signature, member-key shapes, group binding - so group security never depends on an app remembering to call openManifest. That is a deliberate design choice: a security check an integrator can forget is not a security check.

selfstore/households

A household group: several people, each with their own backup file, reading each other’s through crossed read-only links.

function createHouseholdGroup(deps: {
  store: LocalStore;
  kv: KV;
  backend: ShareBackend;
  storageKey?: string;
  wallet?: () => Promise<string | null>;
}): HouseholdGroup;

const HOUSEHOLD_GROUP_KEY = 'selfstore:households:group:v1';

HouseholdGroup

Method Signature What it does
startShare () => Promise<{ fileId, key }> Begin sharing; returns the invite capability
invite () => Promise<{ fileId, key }> Another invite for the same group
openIncoming (fileId, key) => Promise<IncomingShare | null> Read an invite without joining
join () => Promise<'joined' | 'no-invite' | 'mismatch' | 'error'>
syncGroup () => Promise<void> Converge the roster
leave (walletFileId?) => Promise<void>
restore () => Promise<void> Re-attach after a reload
state HouseholdGroupState (readonly)

join() returns the same three named outcomes the join flow exposes: 'mismatch' (this device already follows another share) and 'no-invite' (spent, or meant for someone else) are expected answers, not errors.

HouseholdGroupState

interface HouseholdGroupState {
  active: boolean;
  isAdmin: boolean;
  memberCount: number;
  members: { fileId: string; label: string }[];
  selfFileId: string | null;
  walletFileId: string | null;
  inviteCapability: { fileId: string; key: string } | null;
  memberships: MembershipInfo[];
}

interface MembershipInfo {
  walletFileId: string | null;
  isAdmin: boolean;
  selfFileId: string;
  sharedBy: string | null;
  memberCount: number;
  announcePending: boolean;
  stale: boolean;
}

ShareBackend: what you implement

The group knows nothing about your storage provider. Everything provider-shaped goes through this contract:

Method Signature
createCopy (existingFileId?, shareLabel?) => Promise<CopyLink>
copyTarget (fileId: string) => BackupTarget
dropCopy (fileId: string) => Promise<void>
publishBulletin (key, payload: SharePayload) => Promise<{ fileId, key }>
revokeBulletin () => Promise<void>
openIncoming (fileId, key) => Promise<IncomingShare | null>
takeStashedIncoming () => Promise<{ key, fileId, content } | null>
rereadJoined (fileId, key) => Promise<SharePayload | null | 'unreadable'>
announce (mailboxId, copy: CopyLink) => Promise<void>
takeAnnounces (mailboxId) => Promise<CopyLink[]>
peerSource (link: CopyLink) => PeerSource

rereadJoined returning the literal 'unreadable' rather than null is the distinction that matters: a bulletin that is gone and one your key no longer opens call for different messages - the first is over, the second may be a revocation you should report.

Building it over Google Drive

Most of the file work is already in driveTarget, so a Drive backend is mostly wiring rather than REST:

Port method What it becomes
createCopy driveTarget.createCompanion + driveTarget.share, then driveTarget.owner for the label
copyTarget driveTarget.secondary({ auth, kv }, fileId) - not preview, whose disconnect() ends the primary Drive connection
dropCopy driveTarget.unshare then driveTarget.deleteBackup, in that order
publishBulletin / revokeBulletin a companion of its own, written with exportSnapshot under the link key

What stays yours is the part that needs a server, and it is worth naming before you start: openIncoming, rereadJoined and peerSource all read another member’s file, which the drive.file scope cannot do. announce and takeAnnounces need a channel that outlives a tab. Both take a relay you host, or a gesture per file through the Google Picker. selfstore ships neither on purpose - it would be a server in a library whose whole claim is that there isn’t one.

Codes on the wire

Invites travel as strings. These encode and decode them, and refuse malformed or future-version input loudly:

Export Signature
encodeShare / decodeShare (payload: SharePayload) => string / (code: string) => SharePayload
encodeAnnounce / decodeAnnounce same, for AnnouncePayload
toCopyLink (v: unknown) => CopyLink
toRoster (v: unknown) => CopyLink[]
randomId () => string
class HouseholdCodeError extends Error {
  readonly code: HouseholdCodeErrorCode;   // 'malformed' | 'unsupported-version'
}

toCopyLink and toRoster take unknown on purpose: a code comes off a URL a stranger produced, so it is parsed and validated, never cast.

type CopyLink = DriveCopyLink;

interface DriveCopyLink {
  provider: 'drive';
  fileId: string;
  ownerEmail?: string;
  ownerName?: string;
}

interface SharePayload { v: 1; mailboxId: string; roster: CopyLink[] }
interface AnnouncePayload { v: 1; copy: CopyLink }
interface IncomingShare { projection: Record<string, unknown[]>; share: SharePayload }

The v: 1 field is why decodeShare can fail with 'unsupported-version' rather than misreading a newer code: the version is checked before the shape is trusted.