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.