Skip to main content

Module community

Module community 

Source
Expand description

Persistence for Community protocol local state (GROUP_PROTOCOL.md).

Stores the secrets this account holds: the server-root key and per-channel keys (epoch-tagged). The DB is already account-scoped (account_dir(npub)/vector.db), so there is no npub column — a row belongs to whichever account’s DB it lives in.

At-rest encryption: when Local Encryption is on, every secret BLOB and every identifying metadata field (names, relays, roles, banlist, owner attestation, invite material) is wrapped with the account’s ENCRYPTION_KEY before it touches disk and unwrapped on read, via the enc_*/dec_* helpers below. A raw DB then reveals no WHO/WHERE/WHAT. The discriminators (32-byte raw key vs 60-byte ciphertext; looks_encrypted for text) let a half-migrated DB read back correctly, so the toggle/PIN-rekey flows and the one-time backfill are safe to re-run.

Structs§

InviteLinkSetRow
A folded per-creator public-invite-link set: the creator’s pubkey (hex) and their active link locators. Used to surface “X has N active invite links” in the UI.
PendingCommunityInvite
A parked invite awaiting the user’s accept/decline decision.
PublicInviteRecord
A minted public-invite link the owner retains (to list + revoke).

Functions§

advance_channel_epoch
Apply a received channel rekey’s new key — the atomic archive+head dual-write: in ONE transaction, ARCHIVE (channel, new_epoch) -> new_key in community_epoch_keys AND advance the channel’s read-head (community_channels.epoch + channel_key) iff new_epoch exceeds the current head. A caught-up OLDER epoch is archived (its history stays decryptable) but never regresses the head. Atomic so a crash can’t leave the archive ahead of the head or the reverse. Returns whether the head advanced. Epoch comparison is done in RUST (the u64-as-i64 ≥2^63 SQL mis-order trap).
advance_server_root_epoch
Apply a received SERVER-ROOT (base) rekey’s new root — the base counterpart to advance_channel_epoch, atomic: in ONE transaction, ARCHIVE (server-root scope, new_epoch) -> new_root in community_epoch_keys AND advance the base head (communities.server_root_epoch + server_root_key) iff new_epoch exceeds the current base epoch (monotonic, compared in RUST). A caught-up OLDER base epoch is archived (its control/base history stays decryptable) but never regresses the head. Returns whether the head advanced.
channel_rekeyed_at_server_epoch
The base (server-root) epoch a channel was last rekeyed FOR during a read-cut — the per-channel progress marker that lets a resumed re-founding skip channels already cut. 0 if unknown.
community_created_at_ms
Local first-save time of a community (≈ when this account joined or created it), in ms. created_at is set on the first save and preserved across metadata re-saves, so it tracks the join moment. Used to sort a not-yet-active community by join time. None if unknown.
community_exists
Whether a Community with this id is already stored locally (joined). Cheaper than load_community when only existence matters (e.g. inbound-invite dedup).
community_id_for_channel
The hex id of the Community that owns channel_id, if any is stored locally. Used to resolve a channel-addressed chat back to its Community for sending.
community_invite_join_counts
Per-link join counts for the owner’s public invites: label -> distinct joiners who joined via a link minted by inviter_npub (bech32). Reads the invited-by / invited-label tags on MemberJoined system events; distinct by joiner npub so a rejoin isn’t double-counted. Labels are unique per creator (random fallback ensures it), so (inviter, label) keys a single link.
community_member_activity
Observed participants: the best-effort member list of a Community, newest-active first. Membership is NOT authoritative (a lurker who never posts and never announced won’t appear). A member is included when they have real activity — a posted message/reaction/edit, OR a join presence (kind 3306) — UNLESS that is superseded by a more-recent leave, OR they are banned. So a “leave” actually removes a member, and a leave-then-rejoin/post re-adds them. created_at is in seconds. Result is capped (anti-flood); see [COMMUNITY_MEMBER_CAP].
community_protocol
The protocol a stored community runs, or None if it isn’t held locally.
converge_channel_epoch
SAME-EPOCH convergence for a channel key (concurrent re-founding heal) — the channel counterpart to converge_server_root_epoch. Adopts the winning re-founding’s channel key at epoch (the rekey addressed under the converged server root), replacing the one we minted in our own losing fork. Switches only while the channel is still AT epoch. Returns whether it switched.
converge_edition_head
Converge the head to a same-version fork winner (concurrent-edit resolution). Unlike set_edition_head (which only ADVANCES the version), this resolves a fork AT the current version: two authorized editors editing concurrently from the same base both produce version, and every client must adopt the SAME one. The winner is the lower deterministic inner_id, so this update fires only when the incoming edition ties the stored version AND carries a strictly lower inner_id — monotonic toward the global minimum, so it can never flip-flop (a relay can’t churn the head by reordering, and a held row with a NULL inner_id, pre-migration, is treated as “always replaceable” so it heals to a ranked id). The version-advance path is unchanged and still handled by set_edition_head_with_id; callers run BOTH (advance covers v+1, converge covers a same-v fork).
converge_edition_head_at_epoch
As converge_edition_head, scoped to an EXPLICIT epoch (the epoch the caller’s fold ran under) rather than the community row’s write-time value — same TOCTOU rationale as set_edition_head_at_epoch.
converge_server_root_epoch
SAME-EPOCH convergence for the server root (concurrent re-founding heal): two BAN-holders who re-founded at the same time each sit on their OWN root at the SAME epoch. advance_server_root_epoch refuses to switch (its guard is strictly monotonic), so this is the sibling that REPLACES the head root at epoch with the deterministic winner (lowest root bytes — the caller decides). Archives the new root
delete_community
Remove a Community and all its local state (channels, retained message keys, parked invites, minted public-invite tokens). Used when the user leaves a Community — there is no protocol “leave” (membership is key possession), so leaving is purely local: drop the keys + stop subscribing.
delete_community_retain_keys
self-removal teardown: drop all local community state EXCEPT the held epoch keys (community_epoch_keys). Read access to future epochs is already gone (the post-removal keys are never delivered); retaining the OLD keys only preserves the ability to author a 3305 self-delete of one’s own past messages, each sealed under the epoch key it was sent at. Used by every self-removal trigger (voluntary leave, kick of me, ban-rekey exclusion).
delete_message_key
Remove a retained message key (after a successful deletion publish).
delete_pending_invite
Drop a parked invite without joining (the user declined).
delete_public_invite
Forget a minted public-invite token (after revoking it on relays).
edition_head_entity_ids
The set of control-entity ids (hex) this account tracks a head for. A base rotation gates its head-advance on re-anchoring covering EVERY one of these (not just a matching count), so a relay that withholds one entity’s editions while over-serving another’s can’t slip a thinned control plane past the rotator.
get_all_edition_heads
Every tracked control entity’s persisted head (entity_id hex → (version, self_hash)). This is the per-entity refuse-downgrade FLOOR: the fold seeds each entity’s chain from its held head, so a withholding relay serving editions BELOW what we already hold can’t roll an authority chain back (e.g. resurrecting a since-revoked admin’s old grant). An empty map = a bootstrapping joiner (folds from genesis, floor 0).
get_all_edition_heads_epoched
get_all_edition_heads_full
Every tracked head as entity_hex → (epoch, version, self_hash) — the epoch-primary floor. The caller seeds the fold with ONLY the entities at the community’s CURRENT epoch (a head recorded at a PRIOR epoch belongs to a superseded founding, so its entity folds fresh from the new epoch’s v1 genesis). This is what lets a re-founding’s compacted v1 plane land without a version-only downgrade. Every tracked head as entity_hex → (epoch, version, self_hash, inner_id) — the epoch-primary floor INCLUDING the deterministic tiebreak key, so a fold can resolve a same-version fork at the floor (converge to the lower inner id) instead of wedging on it.
get_community_banlist
A Community’s current banlist (hex pubkeys). Empty for an unknown community or empty list.
get_community_banlist_at
The created_at (secs) of the banlist edition currently stored, or 0 if none. The version floor the rollback guard compares against.
get_community_dissolved
Whether a community has been sealed by a folded + owner-verified GroupDissolved tombstone. false for an unknown community.
get_community_invite_registry
A Community’s current invite-link registry (active link locators, hex). Empty for an unknown community or a Private one. is_public = this is non-empty (computed mode).
get_community_roles
A Community’s cached role graph. Empty (default) for an unknown community or none stored.
get_community_roles_at
The created_at (secs) of the role-graph edition currently stored, or 0 if none.
get_edition_head
The current head (version, self_hash) of a control entity’s edition chain, or None if no edition is held yet (so the next edition is the genesis, version 1, no prev_hash).
get_edition_head_inner_id
The held head’s tiebreak key (inner_id), or None if unheld or pre-migration (NULL). The consumer uses this to decide a same-version convergence exactly as converge_edition_head’s SQL does (a NULL/None held id is “always replaceable”) — so it never applies a display edit the head write would then refuse.
get_guestbook
Load a community’s persisted Guestbook: the raw membership events + the newest-seen cursor (relay seconds). ([], 0) when never synced; unparseable stored JSON degrades the same way (the next sync re-seeds from zero).
get_invite_link_sets
Every creator’s active invite-link set for a Community (creator hex + locators). Empty for a Private community (or one not yet re-folded since this table was added).
get_message_key
Read (WITHOUT removing) the retained key for a message by its INNER message id (what the UI holds). Returns the ephemeral signing Keys, the OUTER event id to NIP-09-delete, and the relay set — or None if not retained (someone else’s message, or already deleted). Peek-only so the key survives a failed deletion publish; the caller removes it with delete_message_key only after the publish succeeds.
get_pending_invite
Read a parked invite’s bundle WITHOUT removing it. Accept is fallible (caps, owner/authority collision), so the row must survive a rejected accept — peek here, then delete_pending_invite only after the join succeeds.
get_read_cut_pending
Whether a PRIVATE-community read-cut re-seal is still outstanding (a prior attempt failed). The ban flow retries the re-seal whenever this is set. false for an unknown community.
get_read_cut_target_epoch
The base epoch a pending read-cut must reach (see set_read_cut_target_epoch). 0 for an unknown community. Reinterpreted i64->u64 (lossless) for epochs >= 2^63.
held_epoch_key
The held key for one specific (scope, epoch), or None if not held. The open path uses this to select the decryption key by the inbound event’s epoch tag.
held_epoch_keys
Every held (epoch, key) for a scope, ascending by epoch. The read paths derive a pseudonym per returned epoch (#z OR-set) so cross-epoch history isn’t stranded. Sorted in Rust (not SQL): epoch is a u64 stored as i64, so a SQL ORDER BY would mis-order epochs >= 2^63.
list_all_public_invites
All minted public-invite links across ALL communities (backfill source for the synced Invite List).
list_community_ids
Ids of every locally-stored Community.
list_pending_invites
All parked invites, newest first.
list_public_invites
All minted public-invite links for a Community, newest first.
load_community
Load a Community and its channels by id. Returns None if not stored locally.
load_community_v2
Load a v2 community by id, or None if absent / not a v2 community.
mark_channel_rekeyed_at_server_epoch
Record that a channel’s key has been rotated to cover base epoch server_epoch (a read-cut step). Best-effort progress marker: written after the channel rekey lands, so a crash before it just re-rotates the channel on resume (safe, the rekey is monotonic) rather than skipping a channel that needed cutting.
pending_invite_exists
Whether an invite for this id is already parked (inbound dedup).
purge_pending_invites_for_held_communities
Drop every parked invite for a community we ALREADY hold — once joined on any device, the invite must never resurface. Ordering-independent: covers the cross-device case where the historical gift-wrapped invites are ingested BEFORE the synced membership list rehydrates those communities (so the ingest-time community_exists guard saw nothing yet). Returns the count purged.
replace_invite_link_sets
Replace ALL of a Community’s per-creator invite-link sets with the freshly-folded set (latest-wins). Replacing wholesale (not upserting) drops a creator who has revoked every link, so the per-creator view stays in lockstep with the flat registry computed in the same fold.
save_community
Persist a Community and all its channels (upsert). Secrets are stored as raw blobs in the account-scoped DB.
save_community_v2
Persist a v2 community + its channels atomically. UPSERT so a metadata re-save preserves banlist/roles (managed by the fold, not here).
save_pending_invite
Park an inbound invite bundle for explicit user consent (the carrier never auto-joins). First-invite-wins: INSERT OR IGNORE means a later invite for the same community_id can’t silently rewrite a parked bundle. Returns whether a new row was inserted (false = already pending, caller should not re-notify).
save_public_invite
Retain a minted public-invite token so the owner can later list + revoke it.
set_community_banlist
Replace a Community’s stored banlist (JSON array of hex pubkeys) + the created_at (secs) of the edition it came from. at is the version: the owner’s own ban/unban writes its freshly built event time, and fetch_and_apply_banlist only calls this with a strictly-newer edition, so the stored banlist can never roll backwards.
set_community_dissolved
Set the owner-dissolution SEAL on a community — PERMANENT + irreversible (no clear path; there is no un-dissolve). Idempotent: re-setting an already-dissolved community is a harmless no-op. Once set, the control fold stops advancing and the inbound path drops every subsequent event. Seal a community as dissolved. Returns whether this call TRANSITIONED it (a live→dissolved flip) so the caller can fire the one-time death notification exactly once — a re-wrapped tombstone (fresh outer id, same owner seal) then can’t spam the handler.
set_community_invite_registry
Replace a Community’s cached invite-link registry (active link locators, hex), folded from the owner-signed vsk=5 edition. Empty = Private. The version floor lives in community_edition_heads (the registry’s own entity), so this is just the content cache (mirrors set_community_banlist).
set_community_roles
Replace a Community’s cached role graph (the aggregated CommunityRoles) + the created_at (secs) of the newest per-entity edition it was built from. at is the version floor: the fetch path only calls this with a strictly-newer aggregate, so the role graph can’t roll backwards (same guard as the banlist).
set_edition_head
Record the current head (version + self_hash) of a control entity’s edition chain (keyless model). The send side reads this to emit the next edition as version+1 citing self_hash as prev_hash; the fold uses it as the per-entity refuse-downgrade floor + anchor. Upserts per (community, entity). inner_id is the head edition’s deterministic tiebreak key (used only by converge_edition_head).
set_edition_head_at_epoch
As set_edition_head_with_id, stamping an EXPLICIT epoch — the epoch the caller’s fold actually ran under — instead of reading the community row at write time. Closes the TOCTOU where a concurrent re-founding bumps server_root_epoch between a fold and its head persist, which would stamp an old-plane version as the new epoch’s floor and wedge the new epoch’s genuine head.
set_edition_head_with_id
As set_edition_head, but also records the head edition’s inner_id (the deterministic tiebreak key), so a later same-version convergence can rank against it. A plain advance carries it through.
set_guestbook
Persist a community’s Guestbook events + cursor (encrypted at rest, like the community row itself). The caller owns dedup/merge — this is a plain replace.
set_read_cut_pending
Mark (or clear) that a PRIVATE-community ban’s base re-seal (read-cut) is OUTSTANDING — set when the re-seal is attempted and cleared only when it succeeds, so a transient failure is retried later instead of silently leaving a banned member with read access.
set_read_cut_target_epoch
Set the base epoch a pending read-cut (re-founding) must reach. The re-seal rotates the base only while server_root_epoch < target, so a retry never double-rotates a base that already advanced. Set to server_root_epoch + 1 on a fresh exclusion delta (ban add / privatize); left untouched on a pure resume so the in-flight target is preserved.
store_epoch_key
Store one held epoch key in the multi-held archive. scope_id is a channel_id hex or crate::community::SERVER_ROOT_SCOPE_HEX. The (community, scope, epoch) PK makes a write for one epoch unable to disturb another epoch’s key — so retained history survives a rekey. Uses REPLACE on the exact coordinate so the fork-resolution apply path can commit the winning key for a contested epoch over a previously-stored loser (the only legitimate same-coordinate overwrite; an epoch key is otherwise immutable).
store_message_key
Retain the ephemeral signing key of a message I published, so I can later NIP-09-delete it. relays is where the deletion must be sent.
take_message_key
Peek + remove in one call. Prefer get_message_key + delete_message_key when a fallible step sits between, so a failure doesn’t strand the key.
upsert_invite_link_set
Upsert ONE creator’s invite-link set (optimistic local update after the local user mints/revokes their own links, mirroring the flat-registry merge). An empty set removes the row.
upsert_public_invite
Insert a public-invite row only if its (decrypted) token isn’t already present — idempotent hydration from the synced Invite List, PRESERVING the original created_at (unlike save_public_invite, which stamps now). Returns true if a row was inserted. Tokens are stored encrypted with a random nonce, so SQL equality can’t dedup; scan + decrypt (few rows per community, owner-only).