Help us shape the future of Go! Take the 2026 Go Developer Survey

group

package
v1.3.100 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 9, 2026 License: AGPL-3.0 Imports: 38 Imported by: 0

Documentation

Overview

Package group pkg/skychat/group/catalog.go c4-app-chat the discovery catalog: "what does this visor host that it wants found?"

Why this is separate from the probe

probe.go answers a question about a group ID you already hold. This answers a question about a PUBLIC KEY, and that is a categorically different disclosure: a probe confirms one 122-bit identifier somebody already had, a catalog turns one key into a list. So it is not the same request with an empty field — it is its own frame, gated on its own per-group opt-in (Record.Listed), and it will never mention a group whose host did not tick the box.

Both live on the same well-known port because both are "ask a visor about its groups" and neither mutates anything; one listener is simpler than two.

What a catalog entry is for

Channels. A broadcast channel is the one shape here that is useless without discovery: a group gets its members from people who already know each other, but a channel wants an audience it has not met, and until now the only way to reach one was for someone to hand you a link. Groups may be listed too — the mechanism does not care — but the default is off and the reason it exists is channels.

What it deliberately is not

Not a network-wide index. There is no aggregator, no crawl, no registration: a catalog is served by the host itself and answers only for the host's own groups, so discovering anything still starts from a public key somebody gave you. That keeps the trust model identical to the rest of skychat — you learn about a visor from a human — while removing the part that was genuinely painful, which was needing a fresh invite link per person for something meant to be public.

Package group pkg/skychat/group/crypto.go c4-app-chat envelope helpers for ModePrivate groups.

Why GCM (not ChaCha20-Poly1305 or noise): every visor binary already pulls in crypto/aes + crypto/cipher for other code paths, keeping the surface area unchanged. GCM also gives us AEAD with 16-byte tag which is exactly what we want for per-message authentication on the feed.

Nonce policy: 12-byte random per message. With a single AES key shared across an arbitrary number of senders, that's the "single-write key" failure mode AEADs warn about — but each message's nonce is independently random, so the probability of nonce reuse across the group is 1 - exp(-N²/2¹⁹⁷), i.e. negligible at any realistic message volume.

Package group pkg/skychat/group/filekey.go c4-app-chat per-file keys for group attachments.

Why attachments needed their own answer

A group message body is sealed under the group key; a file attached to one was not sealed at all. The bytes ride an out-of-band xfer stream (encrypted in transit by the skywire transport, like everything else) and then sit on every member's disk in the clear, next to a chat history whose text is protected. Worse, the bytes are fetched by a backfill request keyed on nothing but the file id: whoever asks the holder for that id gets the file, so a member evicted yesterday — who still knows every id it saw — could keep pulling attachments long after losing the roster seat.

Sealing the bytes under the group key fixes both halves at once: at rest they are ciphertext, and a re-send to someone outside the group hands over bytes they cannot open.

Why a derived key and not the group key

The bulk crypto happens in the chat app (cmd/apps/skychat/commands), which reaches the group manager over the visor's RPC — a different process. Handing the raw group key across that boundary so the app can seal one attachment would put the key that opens the ENTIRE group, past and present, into a second address space for the sake of one file.

So the visor hands over a key scoped to exactly one file:

fileKey = HKDF-SHA256(group key, info = "skychat-group-file-v1" | group id | file id)

A leaked file key opens that attachment and nothing else — not the messages, not another attachment, not the same file in another group. The group key itself never leaves the visor.

Opening takes the same derivation over every key in Record.KeyRing, so an attachment shared before a rotation still opens afterwards, exactly as message history does. A joiner that only holds the current key cannot open attachments from before it arrived — the same boundary admission already draws for message history.

Package group pkg/skychat/group/gossip_seal.go c4-app-chat sealing the governance leaves — roster/, admin/ and mod/ — under the group key, so an encrypted group's membership and moderation history is as private as the messages it protects.

What was readable

Message bodies in a private group have been encrypted from the start. The leaves that say who is IN the group, who holds authority, and who was banned or muted were not. They are signed, so nobody could forge one — but the bytes sitting on the feed were plain JSON:

{"group_id":"…","op":1,"peer_pk":"02ab…","issued_at":"…"}

Anyone who could pull the feed read the group's entire moderation history in the clear: every eviction, every mute, every promotion, and the PK each one named. That set includes an evicted member who kept its copy of the tree, any peer serving backfill, and anyone who got hold of a member's CXO database. "Alice was banned on Tuesday" is exactly the kind of fact about Alice that encrypting the message bodies was carefully hiding, and it was one grep away.

The seal

A governance leaf is now sealed under the same AES-256-GCM group key as message bodies, opened with the same trial-decryption over the key ring (keyring.go), and published as:

"SKG1" | 12-byte nonce | ciphertext+tag

The magic prefix is what makes this compatible in the direction that matters: a leaf that does NOT start with it is a plaintext envelope from an older publisher and is applied exactly as before, so a group that has been running for months keeps converging on the leaves already in its feeds. The reverse does not hold — an old binary reading a sealed leaf sees bytes that are not JSON and drops them, so governance stops flowing toward un-upgraded members until they upgrade. That is the same one-way compatibility window the domain-tag fix in gossip.go took deliberately, for the same reason: it heals on upgrade, and the alternative is keeping the plaintext forever.

The signature stays INSIDE the seal, over the same canonical bytes as before. Sealing is confidentiality only; authority is still the signature plus the current-admin gate in the reconcilers, and neither property depends on the other.

What a sealed leaf still shows

The path prefix (roster/ vs mod/), the byte length, and the timing. An observer learns that some admin issued some moderation decision at some moment — not which member it named, nor what it did. Hiding the fact of governance as well would mean padding every family to one size and publishing decoy leaves; that is a different and much more expensive design, and it is not what this file does.

Only encrypted groups

A public group has no key: admission is open, so the key would go to any stranger who asked and sealing with it would protect nothing (see the Kind/Mode reasoning in group.go). Public governance stays plaintext and the publish path leaves it alone.

Why a joiner still converges

A member holds every key it has ever been given (Record.KeyRing), so sealing costs it nothing: leaves from before the last rotation open on the second or third key it tries. A JOINER is the interesting case, because the admission response hands it the current key only — by design, so admission does not retroactively grant the group's history. Governance leaves published under retired epochs are therefore opaque to it, and this does not break convergence for two independent reasons:

  1. The admission response already carries the full governance snapshot — members, admins, mutes, read-only, backfill (see recordFromInvite) — so the joiner starts converged rather than replaying its way there.
  2. Everything issued from that moment on is sealed under a key it holds, and an admin re-asserts current state under the current key whenever its session opens (BroadcastRoster / BroadcastModeration).

The property that changes is one that was never load-bearing: a joiner can no longer reconstruct the group's moderation history from before it arrived. That is the feature, not a regression.

The parking lot

A sealed leaf can arrive before the key that opens it. The rotation lands on the rotating admin's feed while the roster leaf comes from another admin's, each through its own subscriber, so batch order says nothing about which we see first. Subscriber callbacks fire once per leaf — a leaf we drop because we cannot read it yet is dropped PERMANENTLY, not late. So an unopenable governance leaf is parked here and replayed the moment a key install gives us something new to try, the same lesson the DM ratchet learned in pairing/ratchet.go.

Package group pkg/skychat/group/group.go c4-app-chat built on top of skywire's existing CXO TreeStore feeds.

Architecture (D1, "owner-centric single feed"):

  • One member ("owner") publishes the group's single CXO feed. The feed's SubscriberAllowlist enumerates every group member so only they can subscribe.
  • Other members subscribe to that feed. They receive messages but don't publish; outgoing messages are POSTed to the owner over the existing 1:1 skychat /message endpoint with a group_id tag, and the owner relays them into the feed.
  • Owner offline → group is read-only until owner comes back. Acceptable v1 limit. Phase 2 (project memo) shifts to distributed publishers so this isn't a SPOF.

Group type: the Kind field is the single user-facing switch. It selects BOTH admission (who may join) and payload encryption, because the two answers are not independent — see below.

  • KindPublic: anyone who asks is admitted. Bodies are plaintext JSON on the feed. Encrypting a public group would mean handing the key to every stranger who asks, which protects nothing while adding rotation and stale-key failure modes; plaintext is the honest representation of what "public" means. Note this is about the leaf at rest — the wire is always Noise-encrypted by the transport (dmsg KK / CXO-TCP XX / skynet), so "plaintext" never means readable in flight.

  • KindPrivate: every join is a request an admin approves. Bodies are AES-256-GCM. The key is handed to the joiner inside the approval response over the (already encrypted) relay stream — NOT in the invite link. That is what makes a forwarded link worthless on its own, and it is what made rotation possible: the owner is a live distributor rather than a one-shot link author. Links that still carry a key are accepted for backward compatibility.

Mode is retained as the persisted encryption switch (and the invite-link wire field) so existing records and links keep working unchanged; Kind is derived from it for legacy records by EnsureKind. Read encryption through Record.Encrypted(), not by comparing Mode.

Moderation state (Banned / Muted / ReadOnly) rides alongside the roster and converges through the same signed-gossip reconciler. See moderation.go for the envelope and the enforcement-point table.

The key is not fixed for the life of the group. Evicting a member rotates it, sealed per remaining member over secp256k1 ECDH, so losing the seat also means losing the ability to read what comes next — see keyrotate.go for the distribution and keyring.go for why every visor keeps the keys it has already held.

Package group pkg/skychat/group/invite.go c4-app-chat the invite payload an owner hands to a prospective member.

Invite-link grammar:

skychat:invite:<base64url(json(Invite))>

base64url so a single-token paste through a terminal / chat tab / shell variable doesn't get mangled. URL queries would have worked for ModePublic but the AES key in ModePrivate doesn't urlencode cleanly across every shell.

The whole payload is plaintext on the wire — there's no signature over it. The trust model is "whoever you handed the invite to is trusted to be in the group"; the visor-level allowlist enforces that on the CXO subscribe side, and AES key possession + allowlist together gate access to private group bodies.

AES key generation lives here too because the only callers that need to generate a key are the create + parse paths.

Package group pkg/skychat/group/join.go c4-app-chat the join-request protocol: how a PK holding an invite actually gets an allowlist seat.

Why this exists

Before admission control, an invite link was not sufficient to join anything. A joiner persisted a member Record and dialed the owner's CXO publisher, whose SubscriberAllowlist is exactly Record.Members — so unless an admin had already run AddMember for that PK, the subscribe was refused and Join failed. The only working flow was out-of-band: hand the admin your PK, have them add you, then use the link. The link's whole promise was unbacked.

This file adds the missing step. The joiner asks; the group answers.

Wire

Requests ride the owner's EXISTING relay listener at Record.Port+relayPortOffset — the same dmsg/skynet listener members already dial to submit messages — so no new port, listener, or discovery is introduced. handleRelay dispatches on the envelope's "kind" field.

Backward compatibility is by careful field naming rather than by version negotiation: JoinRequestMsg deliberately has NO "sender_pk" key. An unpatched owner unmarshals the frame into RelayMessage, gets the zero PubKey, and takes its existing "relay rejected: empty sender PK" path — logged and dropped. It cannot mistake a join request for a chat message and publish an empty leaf. The requester simply reads no response and times out, which the manager surfaces as "this group's owner is running an older build".

Identity

The requester's PK is taken from the AUTHENTICATED TRANSPORT, never from the envelope. Every transport under this listener completes a Noise handshake that proves possession of the peer's secret key (dmsg KK, skynet), so conn.RemoteAddr() is a cryptographic claim while a JSON field is just an assertion. requesterPK cross-checks the envelope against the transport and refuses on mismatch, so a mismatched pair is a hard error rather than a silent preference.

Without this, anyone could submit join requests naming a third party: junk on every admin's approval queue at best, and in an open group, unsolicited membership for PKs that never asked.

Package group pkg/skychat/group/join_wire.go c4-app-chat the two halves of the join round trip on the relay listener: handleJoinRequest (group side) and RequestJoin (requester side).

Split from join.go so that file stays pure types + codec, testable without standing up a transport — the same separation gossip.go keeps from roster_reconcile.go.

Package group pkg/skychat/group/joincost.go c4-app-chat what it costs to ask to join, and how fast anyone may ask.

The problem

A skywire public key is free: `cipher.GenerateKeyPair()` and you are somebody else. Every gate in this package is per-PK — the ban list, the approval queue, the allowlist — so an attacker who can mint identities faster than an admin can click "decline" wins by arithmetic. Concretely, before this file:

  • An open group auto-admits whoever asks, so N identities meant N members, N feeds every member subscribes to, and N senders. The roster grows without bound and there is no per-PK gate to hit.
  • A private group queues whoever asks, so N identities meant N rows in every admin's store and N notifications. The queue was the denial-of-service surface: burying real requests under junk costs the attacker nothing and costs the admin their attention.
  • Banning is per-PK and therefore strictly slower than joining.

No central registry can fix that here, and there is nothing to charge. What is available is to make identities cost CPU and to bound the rate at which any of them can consume an admin's resources.

Two mechanisms, deliberately different in kind

**Proof of work** (this file's PoW half) puts a price on each identity. A requester must find a nonce whose hash over (group, its own PK, a recent timestamp) has N leading zero bits. Verification is one hash; production is 2^N. It does not stop an attacker — it converts "free" into "measurable", which is the difference between a script and a budget. Difficulty is per-group so an operator under attack can raise the price without asking anyone to upgrade.

**Rate limiting** (the bucket half) bounds the damage per unit time regardless of how many identities exist or how cheap they were. This is the part that actually protects the admin: even a perfectly executed flood cannot enqueue faster than the bucket refills, and the queue cap means it cannot enqueue more than a bounded amount at all.

PoW without the bucket would let a well-funded attacker back in at scale; the bucket without PoW would still let free identities occupy every slot the bucket allows. Together the attacker pays per identity AND cannot convert money into unbounded queue depth.

What this does not claim

It is not Sybil RESISTANCE. An attacker who wants in badly enough will pay, and a distributed one pays in parallel. The goal is proportionality: make a flood cost enough, and drip slowly enough, that a human admin's decline button keeps up with it.

Package group pkg/skychat/group/key_policy.go c4-app-chat when a group key has covered enough and should be replaced.

Why a group needs this at all

Rotation already existed, but every trigger was an eviction: kick, ban, or an admin clicking the button. A group that nobody is ever removed from therefore ran its ENTIRE life — years of messages — under the one key minted at create time. Every property the epoch machinery looks like it provides collapsed into that: one key compromised is one group compromised, start to finish, and "which epoch" was a field that never changed.

So epochs advance on their own here, on whichever comes first:

  • AGE. The wall-clock window a single key may cover. This is the one that actually bounds "how much of my history does this key open" for a quiet group, where the message count would take years to trip.
  • VOLUME. Messages published under the current key. Bounds a busy group, where a week is a lot of traffic, and bounds AEAD nonce reuse risk far below the birthday bound for random 96-bit nonces.

Who rotates

Only an admin can mint a key (applyKeyLeaf refuses non-admin issuers), so only admins evaluate this. In a group with several admins they all see the same due epoch at roughly the same moment, and a naive implementation has every one of them rotate at once — N rotations, N-1 of them immediately superseded, and each one a leaf every member has to fetch and try to unwrap.

installKey already resolves that race correctly (higher epoch wins; same epoch breaks the tie on IssuedAt), so the duplicates are safe, just wasteful. rotationJitter spreads them: each admin waits an extra deterministic slice of the rotation window derived from its own PK, so in practice one admin reaches the threshold first, publishes, and the rest see a fresh KeyIssuedAt and stand down before their own timer fires. No coordination, no leader election, no shared clock beyond the IssuedAt already in the envelope.

Package group pkg/skychat/group/key_reconcile.go c4-app-chat the session half of key rotation: emitting a keys/<seq> leaf and applying one. Split from keyrotate.go for the same reason roster_reconcile.go is split from gossip.go — the envelope is testable without a transport, this half needs a live publisher.

Package group pkg/skychat/group/keyring.go c4-app-chat the group's key history: which key encrypts new messages, and which older keys still have to open old ones.

Why a ring rather than a key

A ban used to mean "loses network access": the PK lost its allowlist seat, left the roster, and was refused at the join gate. What it did NOT lose was the group key, which it had held since the day it was admitted. Anyone who kept a copy of the feed — or who could still reach any member's publisher through a third party — could keep reading everything published afterwards. The eviction was a perimeter control with the key still inside the perimeter.

Rotating on eviction closes that: a fresh key is generated, sealed individually to each REMAINING member (see keyrotate.go), and used for every message from then on. The evicted PK is not a recipient, so its copy of the old key opens nothing new.

That immediately creates the second problem this file exists for. If a visor held only the current key, rotation would erase the group's history for everyone at once — every message published before the rotation would stop opening. So a visor keeps every key it has ever held: KeyEpoch/AESKey is what encrypts, and KeyRing is what still decrypts. Forward-only, exactly like a mute: a restriction takes away what comes next, it does not rewrite what already happened.

Epochs, not versions

Keys are numbered by epoch. Epoch 0 is the key a group was created with (and the one every record written before rotation existed carries, which is why the zero value is meaningful and needs no migration). Each rotation increments. Two admins rotating at the same moment can mint the same epoch number with different keys; both leaves are valid and every visor ends up holding both keys, so nothing becomes unreadable. Which one wins as "current" is settled by the replay guard's last-writer-wins rule on IssuedAt — see applyKeyLeaf.

Package group pkg/skychat/group/keyrotate.go c4-app-chat distributing a new group key to the members who are still entitled to it — the fourth signed-gossip family, alongside roster/, admin/ and mod/.

The shape of the problem

A rotation has to hand one secret to N specific recipients over a medium every member can read. The CXO feed is exactly that: the leaf sits on the rotating admin's feed and every subscriber sees the bytes. So the key cannot be in the leaf — it has to be in the leaf N times, each copy readable by exactly one recipient.

Hence one KeyWrap per remaining member, each sealed under a key derived from ECDH(recipient_pk, ephemeral_sk). The recipient recomputes the same secret as ECDH(ephemeral_pk, recipient_sk) — the symmetry that makes this work without any prior shared state — and opens its own wrap. Everyone else's wraps are opaque to it. The evicted PK gets no wrap at all, which is the entire point: it is not merely disconnected, it is not a recipient.

secp256k1 + ChaCha20-Poly1305, not NaCl box: skywire identities are secp256k1, and skycoin/cipher.ECDH already does the scalar-mult and SHA256s the shared point into exactly 32 bytes. The same construction the pairing package uses for pair feeds (cmd/apps/skychat/pairing/ crypto.go) — deliberately, so there is one ECDH-sealing idiom in this app rather than two.

Why the wrap key is ephemeral

The wrap secret used to be ECDH(recipient_pk, ISSUER_sk) — the issuer's long-term identity key. That made the epoch machinery decorative against the threat it looks like it addresses: every keys/<seq> leaf stays in the feed forever, so anyone who captured the feed and LATER obtained an admin's identity secret could unwrap every group key that admin had ever issued, all the way back to the group's first epoch. Rotating the group key while the key that unwraps it never changes bounds nothing.

Now each rotation mints a throwaway keypair, seals every wrap under ECDH(recipient_pk, ephemeral_sk), publishes the ephemeral PUBLIC key in the leaf, and drops the ephemeral secret when buildKeyMutation returns. After that the issuer itself cannot re-derive the wrap — its identity key is no longer sufficient to open its own past rotations, so neither is a later compromise of it.

What this does and does not protect

It gives forward secrecy against an evicted member: messages published after the rotation are sealed under a key that member never receives. It does NOT give backward secrecy — the evicted member keeps whatever it already read, and no protocol can take that back.

Against a later identity-key compromise it protects the ISSUER side only. A recipient's wrap is still addressed to that recipient's long-term PK, so whoever obtains a member's identity secret can open every wrap ever addressed to that member. Closing that too would need per-member published prekeys and the round trip to negotiate them; what is here bounds the blast radius of an ADMIN key — the one that unlocks the whole group rather than one seat — and that is the wider hole of the two. Record.KeyRing's bounded retention (keyring.go) is the other half: keys age out of the ring, so even a full-disk compromise reaches back only so far.

Who the wraps are addressed to

The wrap list used to name every recipient PK in the clear, on the argument that it was not new information: roster/ gossip published the same member list in plaintext anyway. Sealing the governance families (gossip_seal.go) removed that argument and left this as the ONE place an encrypted group still published its roster — and worse than the roster, because diffing the recipient sets of two consecutive rotations names exactly who was just evicted, which is the fact the moderation seal exists to hide.

So a wrap is now addressed by a blinded Tag instead: a digest of the same ECDH secret that seals it, which only the intended recipient can recompute. It keeps the direct lookup (a recipient finds its own wrap without trial-decrypting the list) while telling an observer nothing about who is in the group. The tag changes every rotation, because the ephemeral half of the ECDH does, so tags cannot be correlated across epochs either.

What remains visible is the COUNT of wraps — the group's size, and the fact that it grew or shrank. Hiding that needs padding to a fixed recipient count, which trades real bandwidth for a much weaker property than the one bought here, so it is left visible on purpose.

Compatibility runs one way, and further than the governance seal does: a member on a build that predates blinding looks for its wrap BY NAME, finds none, and falls off the key schedule — it keeps the key it has and stops being able to read the group after the next rotation. That is the same upgrade window ephemeral wraps opened (a pre-ephemeral build cannot open a wrap sealed under a throwaway key either), and the two ship together, so blinding does not widen it. Receiving stays backward-compatible in the other direction: a pre-blinding leaf that names its recipient is still matched by name, forever.

Authority

A rotation is an admin action, gated exactly like every other mutation in this family: signature-valid, issued by a CURRENT admin, and fresh by the replay guard. Without the admin gate any member could mint a key and, if others accepted it, split the group into two unreadable halves; without the freshness gate a replayed old rotation would roll the group back onto a key an evicted member still holds.

Package group pkg/skychat/group/manager.go c4-app-chat this visor's collection of group sessions, parallel to pairing.Manager.

The Manager owns the bbolt Store and a map[groupID]*Session of live runtime state. It glues persistence to lifecycle:

  • Create: owner-side; allocates port + AES key (private mode), persists the record, opens the publisher.
  • Join: member-side; takes an invite, persists the record, opens the subscriber + Connect.
  • Leave: member-side; transitions to StatusLeft and tears the session down.
  • Delete: owner-side; revokes the record, tears down.
  • Resume: on startup, walk the store and reopen everything that's still active.
  • SendToGroup: owner-side relay path. Member-side outgoing is handled by skychat's existing 1:1 wire with a group_id tag; the skychat app calls this on the owner side after receiving such a message.

Message fan-out: a single MessageHandler is installed at Manager init and propagated to every Session, so skychat's hub just receives a (groupID, senderPK, msg) tuple and turns it into an SSE event.

Package group pkg/skychat/group/manager_admission.go c4-app-chat the Manager half of admission control: asking to join, ruling on requests, and the moderation commands (kick / ban / mute / read-only).

Split from manager.go, which is already large and is about session lifecycle. The division: manager.go owns "bring sessions up and keep them up", this file owns "who is allowed in and who may speak".

Every command here follows the same three-beat shape, and it matters that they stay in that order:

  1. Authorize + mutate the local Record, and persist it. Local state is the source of truth for this visor even if the network half fails.
  2. Apply to the live Session so enforcement takes effect now rather than after the next restart.
  3. Publish the signed gossip so other visors converge.

Steps 2 and 3 are best-effort: a publisher that is mid-bootstrap shouldn't roll back an admin's decision. Every command is idempotent, so re-issuing it once the session is healthy re-emits the gossip.

Package group pkg/skychat/group/meta.go c4-app-chat display metadata after creation: renaming a group, giving it a picture.

Ownership model — deliberately not gossip

Name and avatar are display metadata with a single natural author: the founding visor. Making them a fifth signed gossip family (alongside roster/admin/mod/keys) would buy multi-admin authorship at the price of canonical-bytes changes, sealing, replay guards and a parking lot — for data that has no integrity stake: nothing admits, decrypts, or moderates based on a name. So the founder's record is simply THE record, and everyone else converges on it by asking:

  • the founder edits locally (Rename / SetAvatar below);
  • new joiners get the current name in the admission response, and the picture on their first refresh (see JoinResponseMsg.Avatar for why not both);
  • members re-learn both through RefreshMeta, which rides the existing describe probe — the UI fires it when a group is opened.

The cost is honesty about staleness: a member that never asks keeps the old name, exactly as a saved contact keeps the name it was saved under.

Package group pkg/skychat/group/mod_reconcile.go c4-app-chat the emit + apply + enforce sides of moderation state.

Mirrors roster_reconcile.go: PublishModMutation writes a signed mod/<seq> leaf onto the issuer's feed, applyModLeaf consumes such leaves from every subscribed feed, and the same authority gate applies — a mutation counts only if it is signature-valid AND issued by a CURRENT admin. Without that gate any member could forge a ban.

The enforcement predicates (senderAllowedToPost, CanPostLocally) live here too, so the "what does muted actually do" answer is in one file rather than spread across the inbox and send paths.

Package group pkg/skychat/group/moderation.go c4-app-chat signed envelopes for the moderation half of admission control: ban / unban, mute / unmute, and the group-wide read-only toggle.

Third member of the gossip family alongside RosterMutation (who is in the group) and AdminMutation (who has authority). Same shape, same authority gate on apply — only a current admin's mutation is applied — and its own domain tag in the canonical bytes so a signature over a roster or admin envelope can never be replayed as a ban. See the domainRoster/domainAdmin/domainMod block in gossip.go.

Where each control is actually enforced

The group topology is federated: every member publishes to their own CXO feed and the others subscribe directly. There is no chokepoint a server could filter at, so the enforcement point differs per control and it's worth being precise about which are structural and which are cooperative.

control     | enforced by                                  | strength
------------|----------------------------------------------|----------
not member  | publisher's SubscriberAllowlist refuses the   | structural
            | CXO subscribe; not in Members so nobody       |
            | subscribes to their feed                      |
ban         | all of the above, PLUS the join-request gate  | structural
            | refuses re-entry, PLUS reader-side drop       |
mute        | reader-side drop of their msgs/ leaves +      | cooperative
            | local composer lock                           |
read-only   | reader-side drop of non-admin msgs/ leaves +  | cooperative
            | local composer lock                           |

"Cooperative" means a patched client can still emit bytes onto its own feed. It cannot make anyone render them: the mute/read-only state is admin-signed, converges to every member through this envelope, and every honest reader drops the leaf before it reaches the inbox. Muting is a moderation control, not a capability revocation — banning is the capability revocation, and it is structural precisely because it takes the allowlist seat away.

Making mute structural would mean routing every message through an admin again, which is the single-point-of-failure the federated design exists to remove. Not worth it to harden a control whose realistic adversary is a spammer with a stock client.

Package group pkg/skychat/group/probe.go c4-app-chat the describe half of admission: "what is the group with this ID?".

Why this exists

A group is reachable only if you know four things — its ID, its host, its feed port, and its mode — and three of those live nowhere but the invite link, because the port is allocated at random per group (defaultPortAlloc) and the mode is not derivable from anything public. That made the short address form (skychat://<pk>/<group-id>, see pkg/skychat/address) impossible to act on, and made "type a public key and let the app work out whether it's a person, a group or a channel" impossible to answer at all: those three are indistinguishable strings.

A probe closes that gap with one round trip on a fixed per-visor port.

Shape

Deliberately built out of the join round trip rather than beside it: same frame kinds, same codec, same identity check, same deadlines. A probe is a JoinRequestMsg with Probe set, answered with JoinStatusInfo. Nothing about it is new except the port it arrives on and the fact that it decides nothing.

What a probe does NOT do

It never mutates: no roster change, no queue entry, no notification, no PoW demanded. That is what makes it safe to answer cheaply, and it is why the well-known port carries describes ONLY — every admission decision stays on the per-group listener at Record.Port+1, which the asker can reach once the probe has told it the port.

Enumeration

A probe is answered only for an exact group ID, and a group ID is a v4 UUID: 122 bits. Knowing one is already a capability, so answering "yes, that is a private group, send a request" leaks nothing that holding the ID did not already imply. There is deliberately no "list your groups" form — that would turn one PK into a directory of every private group a visor hosts, which is exactly the thing the ID's entropy is protecting. A visor that WANTS to be listed can publish its addresses wherever it likes; that is a directory's job, not this port's.

Two things are still withheld from a probe answer even for a group whose ID the asker holds: the roster (who is in it) and the key. Both ride the admission response, after a decision.

Package group pkg/skychat/group/profile.go c4-app-chat "who is the person behind this public key?" — the third question the well-known describe port answers.

Why it lives here

A profile is not a group, and pkg/skychat/profile — which owns what a profile IS, and every rule about what may be in one — knows nothing about this package. What lives here is only the transport: the frame, the dispatch, and the round trip.

It shares the probe port with group describes and the discovery catalog because it is the same kind of question. All three are read-only, all three mutate nothing, all three are asked by someone holding a public key and nothing else, and all three must be answerable BEFORE any relationship exists — which is precisely what a per-group port cannot do. A fourth listener would have been a fourth bind, a fourth accept loop and a fourth port to keep out of the allocator's way, to serve a question the existing one already fits.

What it discloses

Exactly what the visor's operator typed into a dialog, and only if they typed something. An unset profile answers empty rather than refusing: "nothing said" is the honest answer and, unlike an error, it does not let an asker distinguish a visor that has no profile from one that is deliberately withholding it — there is no such state.

Unlike the catalog, this is not opt-in per item. Setting a profile IS the opt-in; a visor that has set none publishes none.

Package group pkg/skychat/group/replay_guard.go c4-app-chat freshness gating for signed gossip, so a superseded mutation cannot be replayed to resurrect state an admin already undid.

The hole this closes

Every roster / admin / moderation mutation is signed, and the reconcilers checked two things: the signature verifies, and the issuer is a current admin. Both hold forever for a leaf that was legitimate when issued — signatures don't expire and admins usually stay admins. Nothing checked whether the mutation was still CURRENT.

Signed leaves are durable and readable by every member, so any member could copy an admin's old `RosterOpAdd(X)` leaf verbatim onto their own feed at a fresh path. Other members decoded it, verified the signature (valid), checked the issuer (a real admin), and applied it — silently re-adding X after X had been removed. The same trick re-promotes a demoted admin (`AdminOpPromote`) or lifts a standing ban (`ModOpUnban`).

Kick was the visible casualty: an eviction could be undone by anyone who had been present when the member was first added, with no forged signature and no admin key.

Why not ParentSeq

The envelopes carry a ParentSeq field, signed and intended by the RFC as a causal chain. It was never populated with anything but zero and no reconciler ever read it. Building real causal ordering means a per-feed DAG with merge rules — a large change, and more than this needs. Last-writer-wins on the mutation's own signed timestamp gives the property that actually matters (superseded state cannot come back) at a fraction of the cost. ParentSeq stays in the signed bytes because removing a field would change every signature; it is simply not the mechanism.

The rule

Per (family, target) keep the IssuedAt of the newest mutation applied so far. A mutation applies only if it is strictly newer. Replays are older, so they lose; genuine updates are newer, so they win.

Two consequences worth stating plainly:

  • Admin clocks must be roughly in agreement. Two admins acting on the SAME target within each other's skew can land out of order, and the later-timestamped one wins regardless of which happened first. This is the normal cost of last-writer-wins and is acceptable here: concurrent contradictory moderation of one PK by two admins is a coordination problem, not a security boundary.

  • A future-dated mutation would pin the watermark and lock the target out of all further updates — a denial of service on the roster requiring only one leaf. maxMutationSkew bounds it.

Package group pkg/skychat/group/roster_reconcile.go c4-app-chat

PublishRosterMutation/PublishAdminMutation write signed roster/<seq> and admin/<seq> leaves onto the issuer's feed, but before this file NOTHING read them: every subscriber subscribed to msgs/ only, and onUpdate had no branch for roster/admin leaves. So a late joiner (which bootstraps its Record as [owner, self] from the invite) never converged to the owner's real member list — the "reconciler (step 3 of the gossip impl)" the comments referenced was never implemented. See skycoin/skywire#3426.

This wires the receive side: subscribers now include the roster/+admin/ prefixes (see subscribedPrefixes), and onUpdate hands each such leaf to applyRosterLeaf / applyAdminLeaf here. The security-critical rule is that a mutation is applied ONLY if it is (a) signature-valid and (b) issued by a CURRENT admin of the group — otherwise any member could forge the roster.

Package group pkg/skychat/group/session.go c4-app-chat lifecycle for one live group, parallel to pairing.Pair.

One Session per (group, role) pair on each visor:

  • Owner role: hosts a treestore.Publisher with SubscriberAllowlist set to every member's PK. Send writes leaves under "msgs/<ts-nano>/<seq>" which subscribers pick up. No subscriber on this side — owner doesn't need to "hear themselves back" since Send fans out locally via the skychat hub before publishing.

  • Member role: hosts a treestore.Subscriber connected to the owner's group feed on the deterministic port from Record.Port. No publisher in D1. Outgoing messages (when wired into skychat) go to the owner via the existing 1:1 pair-control wire with a group_id tag; owner relays into the feed.

Encryption: if the record's Mode is ModePrivate, AES-256-GCM envelope wraps the message body before Put and unwraps on receive. If ModePublic, the body is plaintext JSON. This is the only divergence from pairing.Pair — pairing always encrypts via the ECDH-derived per-pair key.

Phase 2 (project memo project_windows_autoconfig_parity refers only to the Windows track; this is the group-chat track) shifts to distributed publishers so the owner is no longer a SPOF: each member publishes their own message feed, subscribers follow the owner's membership feed and every member's message feed. The Session type as written here is forward-compatible — Phase 2 just spins up multiple Sessions per group, one per remote member.

Package group pkg/skychat/group/signing.go c4-app-chat signatures for chat messages. Each Message published to a group feed is signed by the SenderPK so admin-aggregator republishes are tamper-evident: a non-admin subscriber consuming a leaf via an admin's canonical feed verifies the signature against the original SenderPK before delivering to the handler. The admin can only OMIT (which is detectable by cross-admin divergence), never alter.

Mirrors the canonical-bytes pattern from gossip.go (#2658) so roster/admin envelopes and message leaves use the same primitive (cipher.SignPayload — secp256k1, same algorithm dmsg discovery entries use). Subscribers built before this change accept signature-bearing leaves transparently because the JSON field is omitempty when zero.

Wire format: the Signature field is appended to the existing Message JSON. Backward-compat: legacy leaves on disk (pre-signing publishers) arrive with Signature=zero; VerifyMessage returns ErrLeafUnsignedLegacy which callers can choose to accept with a deprecation warning during the transition window, then flip to strict-reject once publisher TTL elapses.

Package group pkg/skychat/group/store_bbolt.go c4-app-chat persistence for chat groups (native builds; bbolt can't compile under js/wasm, so the browser visor uses the in-memory store_memory.go — same API, wire-identical JSON records).

One bucket "groups" with key = group UUID, value = JSON Record. Layout parallels pairing.Store deliberately so an operator who understands one understands both.

Package group pkg/skychat/group/store_seal.go c4-app-chat encryption at rest for the one part of a group record that is actually a secret: its key material.

What this fixes

groups.db held every private group's AES key as plaintext base64 in a JSON record. Any read of that file — a backup, a synced folder, a support bundle, a stale volume on a decommissioned host, another process running as the same user — handed over the ability to decrypt every message on every private group's feed, past and future.

That defeats the controls built on top of it. Rotating the key on eviction (keyrotate.go) buys nothing if the new key is written back in the clear a millisecond later: whoever can read the file just reads the new one. The same goes for any future control that treats the group key as the thing being protected.

So the key fields are sealed before they are marshaled and opened after they are unmarshaled. Everything else in the record — roster, admins, moderation state, timestamps — stays readable, deliberately: none of it is secret from someone holding the file, all of it converges publicly through signed gossip anyway, and keeping it queryable is what makes the store usable.

What it does NOT fix, stated plainly

The sealing key is derived from the visor's own secret key, and that secret key lives in the visor's config file on the same disk. An attacker who takes BOTH files can derive the sealing key and open everything. This is not a substitute for disk encryption and it is not a passphrase.

What it does buy is that groups.db alone is inert, and groups.db is the file that travels: it gets copied into backups, attached to bug reports, left behind in container volumes, and synced to places the config never goes. Raising the requirement from "read one file" to "read two specific files on the same host" is a real reduction in exposure, and it is the most that can be done without asking an operator for a passphrase on every visor start. A passphrase- or keychain-derived key would fit behind exactly this interface.

Construction

HKDF-SHA256 over the visor secret key with a fixed info string, then ChaCha20-Poly1305 per field with a fresh random nonce prepended — the same AEAD the pairing feeds and the key wraps use, so there is one sealing idiom in this app rather than three.

Each sealed blob is bound to where it belongs with AEAD additional data: the group ID for the current key, and group ID plus epoch for a ring entry. Without that binding, someone with write access to the file could move a sealed blob from one group's record into another's — or from one ring slot to another — and the store would open it happily, silently swapping which key a group encrypts with.

Index

Constants

View Source
const (
	// DefaultJoinPoWBits is what a group asks for unless an admin says
	// otherwise. ~2^18 hashes is a few tens of milliseconds on one core —
	// far below the threshold where a person joining a group notices, and
	// enough that ten thousand identities is minutes of CPU rather than a
	// loop that finishes before you look up.
	//
	// It is deliberately not zero. A default of "off" would mean the
	// protection exists only for operators who already knew to turn it on,
	// which in practice means after the flood.
	DefaultJoinPoWBits uint8 = 18

	// MaxJoinPoWBits caps what any group may demand. The difficulty
	// travels in an invite link, i.e. in attacker-supplied text, and a
	// joiner solves it before it can check anything else — an unbounded
	// value would be a way to burn a stranger's CPU by handing them a
	// link. 26 bits is seconds, not hours.
	MaxJoinPoWBits uint8 = 26
)

Difficulty bounds, in leading zero bits.

View Source
const (
	// DefaultJoinBurst is how many requests a group absorbs back-to-back.
	// Sized for a real event — a team of ten joining a new room at once —
	// rather than for the steady state.
	DefaultJoinBurst = 10

	// DefaultJoinRefill is how long one token takes to come back. Six an
	// hour after the burst is spent: slow enough that a flood is a
	// trickle, fast enough that a genuine queue of newcomers clears in an
	// evening rather than a week.
	DefaultJoinRefill = 10 * time.Minute

	// DefaultMaxPendingJoins caps how many undecided requests a group
	// stores. The queue is an admin's attention, and attention is the
	// resource being attacked: an unbounded queue means the flood wins by
	// making the real requests unfindable. At the cap, new requests are
	// refused with a retryable answer rather than displacing what is
	// already there — a queued PK that has been waiting has already been
	// paid for, and dropping it to make room for the newest arrival would
	// hand the attacker exactly the eviction primitive they want.
	DefaultMaxPendingJoins = 64
)

Join-rate defaults. A token is one request that gets to consume real resources — an admission or a queue insertion. Everything else (a re-ask from a PK already queued, a banned PK, a member re-syncing) is answered without spending one, so an honest requester polling on its 30s timer never runs the bucket down.

View Source
const (
	// DefaultKeyMaxAge is how long one group key may stay current before
	// an admin replaces it.
	//
	// Seven days is a deliberate middle: short enough that a stolen key
	// opens a week of history rather than a lifetime, long enough that a
	// member who is offline for a weekend comes back inside the bounded
	// KeyRing (maxKeyRing = 32 epochs ≈ 7 months at this cadence) and can
	// still read what it missed. Dropping it to hours would push a normal
	// vacation past the ring and lose history for no attacker-facing gain.
	DefaultKeyMaxAge = 7 * 24 * time.Hour

	// DefaultKeyMaxMessages is how many messages one group key may seal
	// before an admin replaces it.
	//
	// 4096 is a traffic bound, not a cryptographic one: AES-GCM with
	// random 96-bit nonces only becomes interesting around 2^32 messages,
	// so this is far below any nonce-collision concern and is chosen for
	// the blast radius instead — one key opens at most a few thousand
	// messages of a busy group.
	DefaultKeyMaxMessages = 4096
)
View Source
const AdminPathPrefix = "admin"

AdminPathPrefix is the parallel prefix for signed admin-mutation envelopes (promote / demote). Same per-feed seq scheme as RosterPathPrefix.

View Source
const DefaultHeartbeatInterval = 30 * time.Second

DefaultHeartbeatInterval is the recommended interval an owner session emits its heartbeat probe. 30s gives a stall-detection window of ~90s without measurable wire overhead on a typical group.

View Source
const HeartbeatMarker = "__skychat_group_heartbeat__"

HeartbeatMarker is the fixed body of an owner-emitted heartbeat message. Subscribers detect it by exact-match on Message.Text and DO NOT bubble the message up to the user handler / inbox — heartbeats are wire noise, not chat. The liveness side effect (bumping lastInboundNs) happens unconditionally at the top of onUpdate, before the heartbeat filter runs.

Picked to be (a) unambiguous (operator typing this verbatim into a chat input would be a deliberate spoof, and the sender PK check still requires it to come from the owner) and (b) recognizable in logs / pcap dumps without decoding the JSON envelope around it.

View Source
const KeyPathPrefix = "keys"

KeyPathPrefix is the CXO path prefix for signed key-rotation envelopes on an admin's feed: "<KeyPathPrefix>/<seq>". Parallel to RosterPathPrefix / AdminPathPrefix / ModerationPathPrefix.

View Source
const MessagePathPrefix = "msgs"

MessagePathPrefix is the prefix used for message leaves within a group feed. Matches the pairing analog so an operator who understands one understands both.

View Source
const MirrorPathPrefix = "mirror"

MirrorPathPrefix WAS the prefix admins used to re-publish observed peer leaves as a redundancy strategy for offline senders (#2584). The publish-on-receive loop + dual-prefix peerSubs subscription it required doubled the per-peer subscription count in any group with admins. During the 2026-05-16/17 reliability runs the extra subscriptions accounted for the bulk of the failure-mode surface: each subscriber's state machine multiplied the chances some path was wedged at any moment.

Removed in this PR. The offline-sender use case the mirroring was meant to address is now covered by per-peer history replay on reconnect (#2659) — a re-attaching subscriber walks the publisher's CXO tree from SinceTimestampNs and gets every leaf the publisher still has, no admin redundancy needed.

The const stays exported so legacy on-disk data referencing the prefix parses cleanly. Current peerSubs no longer subscribe to it and no one publishes under it; leaves left on legacy publishers go unread until publisher restart clears them.

Deprecated: not used by current code; removal scheduled after legacy-publisher TTL elapses.

View Source
const ModerationPathPrefix = "mod"

ModerationPathPrefix is the CXO path prefix for signed moderation envelopes on an admin's feed: "<ModerationPathPrefix>/<seq>". Parallel to RosterPathPrefix / AdminPathPrefix.

ProbePort is the well-known dmsg/skynet port a visor answers group describes on. Aliased from skyenv so this package reads self-contained while the number stays in the one registry that the pair-feed and CXO-feed allocators check against.

View Source
const RosterPathPrefix = "roster"

RosterPathPrefix is the prefix used for signed roster-mutation envelopes within a group feed (cross-visor admin/roster gossip — RFC at docs/skychat_group_gossip_rfc.md, types in gossip.go). Every roster mutation issued by an admin lands at "<RosterPathPrefix>/<seq>" on that admin's own publisher feed; other members' subscribers pick it up and apply through the reconciler (PR-3 of the gossip impl).

Variables

View Source
var (
	// ErrGossipBadSignature is returned by Verify when the signature
	// doesn't bind to (IssuerPK, canonicalBytes). Includes the case
	// where the IssuerPK is the zero value.
	ErrGossipBadSignature = errors.New("gossip: bad signature")

	// ErrGossipMissingFields is returned when a required field is
	// the zero value at sign or verify time (GroupID zero, IssuerPK
	// zero, IssuedAt zero, Signature zero on verify). Catches
	// constructor mistakes before the bad envelope hits the wire.
	ErrGossipMissingFields = errors.New("gossip: missing required field")

	// ErrGossipUnknownOp is returned when Op is outside the defined
	// constant range. A subscriber that receives a future op type
	// drops with this error rather than apply unknown semantics.
	ErrGossipUnknownOp = errors.New("gossip: unknown op")

	// ErrGossipDemoteFounder is returned when an AdminMutation
	// would demote a group's founder. The reconciler rejects on
	// this before applying the local admin-set change. Tested at
	// the unit level by passing the founder PK as PeerPK; the
	// integration with Record.OwnerPK happens in the apply path.
	ErrGossipDemoteFounder = errors.New("gossip: cannot demote founder")
)

Sentinel errors. Tests assert these directly; callers can errors.Is them off the returned err.

View Source
var (
	// ErrJoinNoResponse means the group's relay accepted our frame but
	// never answered — the signature of an owner running a build that
	// predates admission control.
	ErrJoinNoResponse = errors.New("group: join: no response from group (owner may be running an older build)")

	// ErrJoinDenied is returned when an admin refused the request.
	ErrJoinDenied = errors.New("group: join: request denied")

	// ErrJoinBanned is returned when the PK is barred from the group.
	ErrJoinBanned = errors.New("group: join: banned from this group")

	// ErrJoinIdentityMismatch means the envelope's claimed PK did not
	// match the authenticated transport identity.
	ErrJoinIdentityMismatch = errors.New("group: join: requester PK does not match authenticated connection")

	// ErrJoinNoTransportIdentity means the connection carried no
	// authenticated peer key, so the requester could not be
	// identified. Only reachable on a transport that isn't dmsg or
	// skynet, which the relay listener never binds.
	ErrJoinNoTransportIdentity = errors.New("group: join: connection has no authenticated peer identity")

	// ErrJoinNoAdmin means every admin the invite named was reachable
	// but unable to rule (demoted, or no longer holds the group), or was
	// not reachable at all. Retryable: an admin coming back online is
	// all it takes, which is why the requester's record stays in
	// awaiting-approval rather than going terminal.
	ErrJoinNoAdmin = errors.New("group: join: no admin able to admit is reachable")
)

Sentinel errors for the requester side.

View Source
var (
	// ErrLeafBadSignature is returned by VerifyMessage when the
	// signature doesn't bind to (SenderPK, canonicalBytes). The
	// underlying cipher.VerifyPubKeySignedPayload error is wrapped.
	ErrLeafBadSignature = errors.New("group: leaf bad signature")

	// ErrLeafMissingSenderPK is returned when SenderPK is the zero
	// value at sign or verify time. Catches caller mistakes — a
	// zero PK means we don't know who to verify against.
	ErrLeafMissingSenderPK = errors.New("group: leaf missing sender_pk")

	// ErrLeafUnknownCanonicalVersion is returned on Verify when the
	// signed envelope is from a future canonical layout this
	// binary doesn't understand. Old binaries fast-reject newer
	// layouts via this rather than computing a digest over a layout
	// they don't recognize.
	ErrLeafUnknownCanonicalVersion = errors.New("group: leaf unknown canonical version")

	// ErrLeafUnsignedLegacy is returned by VerifyMessage when the
	// Signature field is the zero value. Distinct from
	// ErrLeafBadSignature so the inbox path can downgrade to a
	// debug log + accept during the deprecation window, then flip
	// to strict-reject once unsigned publishers have aged out.
	ErrLeafUnsignedLegacy = errors.New("group: leaf unsigned (legacy publisher)")
)

Sentinel errors. Callers errors.Is() against these; tests assert directly.

View Source
var ErrCatalogUnreachable = errors.New("group: catalog: host did not answer")

ErrCatalogUnreachable means the host could not be reached or answered nothing — including a host running a build with no catalog at all, which is indistinguishable from silence and equally retryable.

View Source
var ErrGossipBanFounder = errors.New("gossip: cannot ban or mute founder")

ErrGossipBanFounder is returned when a moderation mutation would ban or mute the group's founder. The founder is the recovery anchor for the whole record (immutable admin, invite trust root); letting a promoted admin silence them would be a takeover primitive.

View Source
var ErrGossipSealedUnreadable = errors.New("group: sealed governance leaf: no key opens it")

ErrGossipSealedUnreadable is returned when a leaf is sealed and no key this visor holds opens it. Distinct from a decode failure because the caller's response is different: a leaf we cannot read YET is parked for replay, not dropped.

View Source
var (
	// ErrKeyRotationNotEncrypted is returned when a rotation is requested
	// for a group whose bodies are plaintext. There is no key to rotate,
	// and pretending otherwise would suggest a protection the group does
	// not have.
	ErrKeyRotationNotEncrypted = errors.New("group: key rotation: group is not encrypted")
)

Sentinel errors for the rotation path.

View Source
var ErrKindImmutable = errors.New("group: a group's kind cannot be changed after it is created")

ErrKindImmutable is returned by the store when a write would change an existing group's Kind.

View Source
var ErrPostNotPermitted = errors.New("group: posting not permitted")

ErrPostNotPermitted is returned by Send when local moderation state forbids this visor from posting. Wrapped with the operator-facing reason so the UI can show it verbatim.

View Source
var ErrProbeNoSuchGroup = errors.New("group: probe: host does not hold that group")

ErrProbeNoSuchGroup means the host answered, and does not hold a group with that ID (or holds one it has left or deleted). Distinct from a transport failure: the address is wrong, not unreachable, so a caller should say "no such group" rather than "try again".

View Source
var ErrProbeUnreachable = errors.New("group: probe: host did not answer")

ErrProbeUnreachable means the host could not be reached, or answered nothing. The group may well exist — this is the retryable case, and also what an older host that does not serve ProbePort looks like.

View Source
var ErrProfileUnreachable = errors.New("group: profile: host did not answer")

ErrProfileUnreachable means the host could not be reached or answered nothing — including a host running a build that predates profiles, which is indistinguishable from silence.

Callers are expected to treat this as "no profile", not as a failure to report: every path that fetches one has a perfectly good fallback in the public key itself.

View Source
var ErrRelayNoAck = errors.New("group: relay: no ack within timeout (owner pre-ack or slow publish)")

ErrRelayNoAck signals that the relay message bytes were written successfully but no RelayAck arrived within relayAckReadTimeout (or the owner is on a pre-ack binary). Caller can treat this as "delivered to the owner's wire but not confirmed-published" — e.g. surface to the operator as "sent (unconfirmed)" rather than either a clean success or a hard failure.

View Source
var ErrStoreSealKeyRequired = errors.New("group: store: a visor secret key is required to seal group keys at rest")

ErrStoreSealKeyRequired is returned by OpenStore when handed a zero secret key. Refusing is deliberate: silently falling back to plaintext keys on disk is exactly the state this file exists to end, and a caller with no identity to derive from has a configuration bug rather than a weaker security posture.

Functions

func Decrypt

func Decrypt(key, ciphertext, nonce []byte) ([]byte, error)

Decrypt is the inverse of Encrypt. Failures (wrong key, mangled ciphertext, mangled nonce, tag check) all surface as errors — callers should display "[decryption failed]" or similar on the feed line rather than the raw error to avoid leaking which kind of failure occurred to a passive observer.

func EncodeInvite

func EncodeInvite(inv Invite) (string, error)

EncodeInvite returns the printable invite-link form.

func Encrypt

func Encrypt(key, plaintext []byte) (ciphertext, nonce []byte, err error)

Encrypt produces (ciphertext, nonce) for a ModePrivate group. Caller is responsible for tagging the resulting Message body with Ciphertext + Nonce. Returns an error iff the key length is wrong or the underlying RNG fails.

func GenerateAESKey

func GenerateAESKey() ([]byte, error)

GenerateAESKey returns a fresh 32-byte key sourced from crypto/rand. Used at create time for ModePrivate groups.

func IsHeartbeat

func IsHeartbeat(msg Message) bool

IsHeartbeat returns true for messages that are owner-emitted heartbeat probes rather than chat content. Exposed so the visor's inbox layer (pkg/visor/group.go) can filter heartbeats from the poll ring without re-importing the constant in two places.

func MarshalAdmin

func MarshalAdmin(m AdminMutation) ([]byte, error)

MarshalAdmin returns the JSON wire encoding of m.

func MarshalKey

func MarshalKey(m KeyMutation) ([]byte, error)

MarshalKey returns the JSON wire encoding of m.

func MarshalMod

func MarshalMod(m ModerationMutation) ([]byte, error)

MarshalMod returns the JSON wire encoding of m.

func MarshalRoster

func MarshalRoster(m RosterMutation) ([]byte, error)

MarshalRoster returns the JSON wire encoding of m. Trivial wrapper around encoding/json — present so callers don't reach past the package boundary for the encoder.

func SignAdmin

func SignAdmin(m *AdminMutation, sec cipher.SecKey) error

SignAdmin is the parallel signer for AdminMutation.

func SignKey

func SignKey(m *KeyMutation, sec cipher.SecKey) error

SignKey fills in IssuerPK + Signature on m using sec. Caller owns GroupID / Epoch / Wraps / ParentSeq / IssuedAt.

func SignMessage

func SignMessage(m *Message, sec cipher.SecKey) error

SignMessage fills in m.Signature using sec. m.SenderPK MUST already be populated (and must match the PK derived from sec — the function does NOT overwrite SenderPK because publishAs's caller passes both explicitly). m.TS must be set; zero TS hashes to a deterministic-but-meaningless slot so we don't sign it.

Returns ErrLeafMissingSenderPK on zero SenderPK; the underlying cipher.SignPayload error otherwise.

func SignMod

func SignMod(m *ModerationMutation, sec cipher.SecKey) error

SignMod fills in IssuerPK + Signature on m using sec. Caller owns GroupID / Op / PeerPK / ParentSeq / IssuedAt.

Returns ErrGossipMissingFields when the zero-value rules are violated, ErrGossipUnknownOp for an undefined op, or the underlying cipher.SignPayload error.

func SignRoster

func SignRoster(m *RosterMutation, sec cipher.SecKey) error

SignRoster fills in IssuerPK + Signature on m using sec. m must have GroupID, Op, PeerPK, IssuedAt, and (if non-zero) ParentSeq already populated. The function does NOT mutate Op / GroupID / PeerPK / ParentSeq / IssuedAt — caller owns those.

Returns ErrGossipMissingFields when required inputs are zero, ErrGossipUnknownOp when Op is out of range, or the underlying cipher.SignPayload error.

func VerifyAdmin

func VerifyAdmin(m AdminMutation) error

VerifyAdmin is the parallel verifier for AdminMutation.

func VerifyJoinPoW

func VerifyJoinPoW(groupID string, requester cipher.PubKey, p JoinPoW, requiredBits uint8, now time.Time) (bool, string)

VerifyJoinPoW checks a proof against the difficulty this group requires right now. Returns the reason when it fails, for the log and for the challenge sent back.

The requester's PK comes from the AUTHENTICATED transport, never from the envelope (see join.go), so the proof is bound to an identity the requester actually holds rather than one it claims.

func VerifyKey

func VerifyKey(m KeyMutation) error

VerifyKey checks that m.Signature binds m.IssuerPK to the canonical bytes. Stateless — it does NOT check that the issuer is a current admin (applyKeyLeaf does that) nor that the epoch is an advance.

func VerifyMessage

func VerifyMessage(m Message) error

VerifyMessage checks m.Signature against m.SenderPK over the canonical-bytes of m's other fields. Returns nil on success; ErrLeafUnsignedLegacy when m.Signature is the zero value (caller decides accept-with-warning vs strict-reject); ErrLeafBadSignature when the signature doesn't bind; ErrLeafMissingSenderPK when SenderPK is zero.

Cheap by design — VerifyMessage runs on every inbound leaf in the hot onUpdate path. The canonical bytes are already a 32-byte SHA-256 digest; cipher.VerifyPubKeySignedPayload is a single secp256k1 verify on top.

func VerifyMod

func VerifyMod(m ModerationMutation) error

VerifyMod checks that m.Signature binds m.IssuerPK to the canonical bytes. Stateless — it does NOT check that the issuer is a current admin (applyModLeaf does that) nor that ParentSeq is sensible.

func VerifyRoster

func VerifyRoster(m RosterMutation) error

VerifyRoster checks that m.Signature binds m.IssuerPK to the canonical bytes. Returns nil on success, ErrGossipBadSignature on mismatch, ErrGossipMissingFields when the envelope is incomplete, ErrGossipUnknownOp on out-of-range Op.

Verification is stateless: it does NOT check that IssuerPK is in the local admin set (the reconciler does that), nor that ParentSeq is sensible (also the reconciler's job). Those are data-plane checks against current local state; this function is the cryptographic-only check.

Types

type AdminMutation

type AdminMutation struct {
	GroupID   uuid.UUID     `json:"group_id"`
	Op        AdminOp       `json:"op"`
	PeerPK    cipher.PubKey `json:"peer_pk"`
	ParentSeq uint64        `json:"parent_seq"`
	IssuedAt  time.Time     `json:"issued_at"`
	IssuerPK  cipher.PubKey `json:"issuer_pk"`
	Signature cipher.Sig    `json:"signature"`
}

AdminMutation is one signed instruction to promote or demote a peer in the admin set. Same envelope shape as RosterMutation minus the Op type. Two structs (not one with a polymorphic Op) keeps each mutation's path-on-the-feed scope obvious to the subscriber's pattern-match.

func UnmarshalAdmin

func UnmarshalAdmin(data []byte) (AdminMutation, error)

UnmarshalAdmin is the parallel decoder for AdminMutation.

type AdminOp

type AdminOp uint8

AdminOp is the kind of admin-set mutation. Parallel to RosterOp.

const (
	// AdminOpPromote grants admin authority to PeerPK.
	// Idempotent: applying twice is a no-op.
	AdminOpPromote AdminOp = 1
	// AdminOpDemote revokes admin authority from PeerPK.
	// Idempotent: applying to a non-admin is a no-op. The founder
	// PK (Record.OwnerPK) cannot be demoted — Apply rejects the
	// mutation per the reconciliation rule.
	AdminOpDemote AdminOp = 2
)

type CatalogEntry

type CatalogEntry struct {
	ID        string        `json:"id"`
	HostPK    cipher.PubKey `json:"host_pk"`
	Name      string        `json:"name"`
	Kind      Kind          `json:"kind"`
	Mode      Mode          `json:"mode"`
	Policy    JoinPolicy    `json:"policy"`
	Port      uint16        `json:"port"`
	PoWBits   uint8         `json:"pow_bits,omitempty"`
	PriceHint string        `json:"price_hint,omitempty"`

	// Address is the canonical skychat:// form, so a caller can hand a row
	// straight to the join path or render it as a QR code. Built by the
	// RECEIVER from HostPK + ID rather than trusted from the wire — a host
	// that could name its own address string could name someone else's.
	Address string `json:"address"`
}

CatalogEntry is one discovered group, host-side or caller-side.

func (CatalogEntry) Invite

func (e CatalogEntry) Invite() Invite

Invite rebuilds the invite this entry is equivalent to, so a discovered row joins through exactly the same path as a pasted link. Carries no key, for the same reason GroupDescriptor.Invite does not.

func (CatalogEntry) IsChannel

func (e CatalogEntry) IsChannel() bool

IsChannel reports whether this entry is a broadcast channel.

type CatalogEntryMsg

type CatalogEntryMsg struct {
	ID        string     `json:"id"`
	Name      string     `json:"name"`
	GroupKind Kind       `json:"group_kind"`
	Policy    JoinPolicy `json:"policy"`
	Port      uint16     `json:"port"`
	PoWBits   uint8      `json:"pow_bits,omitempty"`
	PriceHint string     `json:"price_hint,omitempty"`
}

CatalogEntryMsg is one published group on the wire.

A subset of GroupDescriptor: enough to render a row and then join it, and nothing about the roster. Note it carries no member count — a listing is an invitation to join, not a report on who already did, and the count is the one field that turns a catalog into surveillance of a channel's popularity.

type CatalogRequestMsg

type CatalogRequestMsg struct {
	Kind string    `json:"kind"`
	TS   time.Time `json:"ts"`
}

CatalogRequestMsg asks a visor what it publishes.

Carries no filter and no cursor on purpose. A filter would invite probing ("do you host anything called X?") against unlisted groups, and the answer is bounded by maxCatalogEntries anyway.

type CatalogResponseMsg

type CatalogResponseMsg struct {
	Kind    string            `json:"kind"`
	HostPK  cipher.PubKey     `json:"host_pk"`
	Entries []CatalogEntryMsg `json:"entries"`

	// Truncated reports that the host had more listed groups than it sent,
	// so a caller can say so rather than presenting a partial list as
	// complete. See maxCatalogEntries.
	Truncated bool `json:"truncated,omitempty"`
}

CatalogResponseMsg is the host's answer.

type Config

type Config struct {
	// MyPK / MySK identify this visor.
	MyPK cipher.PubKey
	MySK cipher.SecKey

	// Record is the persisted group state. The session inherits ID,
	// OwnerPK, Port, Mode, AESKey, Members, and Role from it. The
	// caller must have already loaded this from group.Store.
	Record Record

	// DmsgC is the shared DMSG client.
	DmsgC *dmsg.Client

	// DataDir is the per-visor parent directory for CXO state. Each
	// Session carves its own group/<id>/ subtree inside it. Ignored (and
	// may be empty) when InMemoryDB is set.
	DataDir string

	// InMemoryDB runs the session's CXO tree entirely in memory, with no
	// DataDir / filesystem access. Required for the browser (js/wasm) visor,
	// which has no disk. The native-TCP standalone path already forces this
	// internally (see newPublisher); this flag extends it to the dmsg path so a
	// wasm visor can be a full federated group member — it publishes its own
	// feed in memory and subscribes to peers over dmsg, and the network retains
	// whatever it published (state resets on tab reload; re-subscribe replays).
	InMemoryDB bool

	// Logger is optional; nil falls back to a tag-based default.
	Logger *logging.Logger

	// BatchWindow forwards to the publisher (see treestore.PubConfig).
	BatchWindow time.Duration

	// HeartbeatInterval, when > 0, makes owner-role sessions emit a
	// periodic no-op heartbeat probe into the group feed. Members
	// observe the heartbeats via onUpdate, which bumps lastInboundNs
	// (the unified liveness signal) just as a real message would.
	// Heartbeats are what keep IsSubscriberAlive true on quiet groups
	// where no chat traffic flows for long stretches. Zero disables
	// heartbeat emission entirely (useful for tests and for
	// deployments that want to opt out of the ~1KB/min/group wire
	// cost). Recommended production value: 30s.
	HeartbeatInterval time.Duration

	// TCPListenAddr selects native-TCP transport instead of dmsg: when
	// DmsgC is nil and this is set, the session's CXO publisher listens
	// here over the node's built-in TCP transport (no dmsg/discovery).
	// Used by the standalone skychat --cxo group mode.
	TCPListenAddr string

	// PeerAddrs maps a peer PK to its "host:port" for the TCP path
	// (dmsg resolves PK->addr via discovery; native TCP has none, so the
	// caller supplies addresses). Consulted by the per-peer dial loop in
	// TCP mode; ignored under dmsg.
	PeerAddrs map[cipher.PubKey]string

	// PeerFanout is how many non-admin peers a non-admin member follows
	// beyond the admins — the local resource limit on the replication
	// factor. Zero takes defaultPeerFanout; negative disables the fanout
	// on this visor regardless of the group's own setting.
	//
	// Two switches govern this, deliberately: the GROUP decides whether
	// backfill-from-any-member is allowed at all (Record.PeerBackfill-
	// Disabled, an admin decision that converges to everyone), and each
	// visor decides how much of it to pay for. Neither can force the
	// other's hand.
	PeerFanout int
}

Config bundles the inputs Session.Open needs. Mirrors pairing.Config where it makes sense; Record + Role replace the PeerPK-shaped fields.

type CreateOption

type CreateOption func(*Record)

CreateOption adjusts a group at create time. Variadic so the three existing call sites (visor RPC, wasm, CLI) keep compiling, and so a future create-time choice is one more option rather than another parameter on every caller.

func WithListed

func WithListed(listed bool) CreateOption

WithListed opts the new group into this visor's discovery catalog, so anyone holding the host's public key can find it.

Off when the option is not passed, and that default is load-bearing: the catalog is the only thing here that turns one key into a list, so an entry has to be asked for. See Record.Listed.

func WithPeerBackfill

func WithPeerBackfill(enabled bool) CreateOption

WithPeerBackfill sets whether any online member may serve this group's history to a joiner, or only admins. The creator's choice at create time; an admin can change it later with Manager.SetPeerBackfill.

Enabled is the default when the option is not passed — see Record.PeerBackfillDisabled for what the setting costs and protects.

type GroupDescriptor

type GroupDescriptor struct {
	// ID and HostPK are echoed back so a descriptor is self-describing
	// once it has been handed to a UI or an RPC caller.
	ID     string        `json:"id"`
	HostPK cipher.PubKey `json:"host_pk"`

	Name string `json:"name"`
	Kind Kind   `json:"kind"`
	Mode Mode   `json:"mode"`

	// Policy is what joining will involve: JoinOpen admits on request,
	// JoinApproval queues for an admin.
	Policy JoinPolicy `json:"policy"`

	// Port is the group's CXO feed port, needed to build the invite the
	// join path consumes.
	Port uint16 `json:"port"`

	// PoWBits is the proof of work a join request must carry, so the
	// joiner pays before it dials rather than being challenged.
	PoWBits uint8 `json:"pow_bits,omitempty"`

	// ReadOnly reports that posting is currently suspended for
	// non-admins. Separate from a channel: this one is reversible.
	ReadOnly bool `json:"read_only,omitempty"`

	// PriceHint is unverified display text about admission cost. See
	// JoinResponseMsg.PriceHint — never treat it as terms.
	PriceHint string `json:"price_hint,omitempty"`

	// Banned reports that the ASKING visor is barred from this group, so
	// a UI can say why rather than offering a join that will be refused.
	Banned bool `json:"banned,omitempty"`

	// Avatar / AvatarMime are the group's picture, already validated on
	// receipt (see sanitizeAvatar). As public as the name: whoever may ask
	// what a group is called may see what it looks like.
	Avatar     []byte `json:"avatar,omitempty"`
	AvatarMime string `json:"avatar_mime,omitempty"`
}

GroupDescriptor is what a probe learns about a group. Everything a caller needs to render a join affordance and then perform the join, and nothing that a non-member should not see.

func (GroupDescriptor) Invite

func (d GroupDescriptor) Invite() Invite

Invite rebuilds the invite this descriptor is equivalent to, so the existing join path — which speaks Invite and nothing else — can be driven from a short address.

The AES key is absent by construction, which is correct: an encrypted group's key travels in the admission response, after a decision. That is the same shape a freshly minted link has (see Manager.BuildInvite), so joining from an address and joining from a link converge on identical code.

Admins are left empty: a probe is answered by ONE host, and naming further admins would be repeating an unverified claim to ourselves. The real admin set arrives with the admission response.

func (GroupDescriptor) IsChannel

func (d GroupDescriptor) IsChannel() bool

IsChannel reports whether the described group is a broadcast channel.

type GroupKey

type GroupKey struct {
	// Epoch is the key's generation. Matches the KeyMutation that
	// distributed it, or 0 for the group's create-time key.
	Epoch uint64 `json:"epoch"`

	// Key is the 32-byte AES-256-GCM key. In memory only — the store
	// swaps it for Sealed on the way to disk, exactly as it does for
	// Record.AESKey. Kept tagged so pre-sealing records still open.
	Key []byte `json:"key,omitempty"`

	// Sealed is Key encrypted at rest. Present on disk, absent in
	// memory; never populate it by hand. See store_seal.go.
	Sealed []byte `json:"key_sealed,omitempty"`

	// AddedAt is when THIS visor learned the key, not when it was
	// issued. Only used to order the ring for eviction, so a peer with a
	// skewed clock cannot make us drop a key we still need.
	AddedAt time.Time `json:"added_at"`
}

GroupKey is one retired group key, kept so old messages still open.

type Invite

type Invite struct {
	ID      string        `json:"id"`
	Name    string        `json:"name"`
	OwnerPK cipher.PubKey `json:"owner_pk"`
	Port    uint16        `json:"port"`
	Mode    Mode          `json:"mode"`

	// Kind is the group type, carried because Mode alone can no longer
	// name it: a public group and a channel are both ModePublic, and
	// differ only in who may publish. Without this a channel joined
	// from a link would come up as an ordinary public group and its
	// members' composers would be unlocked.
	//
	// Absent in links minted before channels existed; a joiner reading
	// one derives the kind from Mode, which is exactly the old
	// behavior. Untrusted like the rest of the link, and cross-checked
	// against Mode on decode — the group's own answer to the join
	// request is what a joiner ultimately records.
	Kind Kind `json:"kind,omitempty"`

	// Admins are further PKs with roster authority that a joiner may ask
	// for admission when the founder doesn't answer. Excludes OwnerPK,
	// which is carried above and is always tried first.
	//
	// Without this the founder was a single point of failure in the one
	// place a group can least afford one: every invite pointed join
	// requests at OwnerPK alone, so a founder that was offline meant
	// nobody could join, and a founder whose key was lost meant the
	// group could never admit anyone again — while other admins sat
	// there, authorized and idle. The founder stays the recovery anchor;
	// it just stops being the only door.
	//
	// Absent in links minted before this field existed. A joiner reading
	// one falls back to the founder alone, which is exactly the old
	// behavior; an older joiner reading a link that HAS the field
	// ignores it and dials the founder, same as it always did.
	Admins []cipher.PubKey `json:"admins,omitempty"`

	// PoWBits is how much proof of work the group wants with a join
	// request. Carried so the common case costs no extra round trip: the
	// joiner pays before it dials, and only a stale or absent value turns
	// into a challenge from the responder.
	//
	// Untrusted, like the rest of the link — it is clamped to
	// MaxJoinPoWBits on read so a hostile invite cannot make whoever
	// pastes it grind for hours. The responder checks against its own
	// requirement regardless, so a link understating the price costs the
	// joiner one extra round trip rather than getting anyone in cheaply.
	PoWBits uint8 `json:"pow_bits,omitempty"`

	// AESKey is the group key, and is now normally ABSENT even for
	// ModePrivate: the key is handed to a joiner in the approval
	// response over the encrypted relay stream, once an admin has said
	// yes. See JoinResponseMsg.AESKey.
	//
	// It used to travel here, which made the invite itself the secret —
	// anyone the link reached could decrypt the feed, a denied
	// requester included, and the key could never be rotated because
	// links already in circulation would keep working. Links minted by
	// older builds still carry it and are still accepted; the legacy
	// join path uses it when a group doesn't answer the join request.
	AESKey []byte `json:"aes_key,omitempty"`
}

Invite is the on-the-wire content of an invite link. Mirrors the subset of group.Record an invitee needs to (a) bootstrap a local Record and (b) start the CXO subscribe.

func DecodeInvite

func DecodeInvite(s string) (Invite, error)

DecodeInvite parses an invite-link string back into an Invite. Tolerates leading/trailing whitespace because operators paste these from terminals.

func (Invite) AdmissionTargets

func (inv Invite) AdmissionTargets(self cipher.PubKey) []cipher.PubKey

AdmissionTargets returns the ordered PKs this invite says may admit us: the founder, then the named admins. self is excluded so we never dial ourselves. See admissionOrder for the ordering rationale and the cap.

func (Invite) InviteKind

func (inv Invite) InviteKind() Kind

InviteKind returns the group type this invite names, falling back to what Mode implies for links minted before Kind was carried. The single accessor callers should use so the legacy fallback lives in one place.

type JoinPoW

type JoinPoW struct {
	// TSUnix is the timestamp the proof is bound to, in Unix seconds.
	// Inside the hash, so changing it invalidates the work.
	TSUnix int64 `json:"ts_unix"`

	// Nonce is the value the requester searched for.
	Nonce uint64 `json:"nonce"`

	// Bits is the difficulty the requester believes was asked of it.
	// Advisory: the verifier checks the hash against ITS OWN requirement
	// and ignores this. Carried so a mismatch is visible in logs when a
	// stale invite is circulating.
	Bits uint8 `json:"bits"`
}

JoinPoW is a solved proof of work carried by a join request.

func SolveJoinPoW

func SolveJoinPoW(groupID string, requester cipher.PubKey, bits uint8, at time.Time, deadline time.Time) (JoinPoW, bool)

SolveJoinPoW searches for a nonce meeting bits, giving up when ctx-less deadline passes. Returns the proof and whether it succeeded.

Deliberately simple and single-threaded: at the difficulties this package allows it finishes in well under a second, and burning every core of a joiner's machine to shave milliseconds off a once-per-group operation would be the wrong trade.

type JoinPolicy

type JoinPolicy string

JoinPolicy is the admission rule for a group.

const (
	// JoinOpen — any PK that asks is admitted immediately, provided
	// it isn't banned. The request round-trip still happens: it's
	// what puts the joiner in the roster so other members subscribe
	// to their feed.
	JoinOpen JoinPolicy = "open"

	// JoinApproval — the request is queued for an admin, who
	// approves or denies it. The requester polls for the outcome.
	JoinApproval JoinPolicy = "approval"
)

type JoinRequest

type JoinRequest struct {
	GroupID string        `json:"group_id"`
	PK      cipher.PubKey `json:"pk"`
	Note    string        `json:"note,omitempty"`
	AskedAt time.Time     `json:"asked_at"`

	// PoW is the proof the requester sent, handed to the policy layer so
	// it can verify against the group's current price. Not persisted with
	// the queue entry — it is spent on arrival, and keeping it would
	// invite someone to treat a stored proof as still valid.
	PoW JoinPoW `json:"-"`

	// AskAgain mirrors the wire flag: a deliberate retry after a decline
	// (see JoinRequestMsg.AskAgain). Like PoW it is a property of THIS
	// arrival, not of the queue entry, so it is not persisted.
	AskAgain bool `json:"-"`

	// Status is pending until an admin rules. Approved requests are
	// recorded as admitted AND added to Members; the queue entry stays
	// as an audit trail.
	Status JoinStatus `json:"status"`

	DecidedAt time.Time     `json:"decided_at,omitempty"`
	DecidedBy cipher.PubKey `json:"decided_by,omitempty"`
}

JoinRequest is one entry in an admin's pending-approval queue. The persisted form of a JoinRequestMsg plus its decision state.

Kept after a decision (rather than deleted) so an admin can see that they already refused a PK instead of re-litigating the same request every time it is re-sent, and so a denial can be reversed without waiting for the requester to ask again.

func (JoinRequest) IsPending

func (r JoinRequest) IsPending() bool

IsPending reports whether this request still awaits a decision.

type JoinRequestHandler

type JoinRequestHandler func(req JoinRequest) JoinResponseMsg

JoinRequestHandler decides one authenticated join request and returns the answer to write back. Installed on a Session by the Manager, which owns the admission policy, the roster mutation an admission implies, and the store the pending queue lives in.

The session has already proven the requester's identity against the transport before this runs, so req.PK can be trusted. Implementations must not block for long: the requester is holding a stream open on joinResponseReadTimeout.

type JoinRequestMsg

type JoinRequestMsg struct {
	Kind        string        `json:"kind"`
	GroupID     string        `json:"group_id"`
	RequesterPK cipher.PubKey `json:"requester_pk"`

	// Note is optional free text ("hi, I'm from the ops team") shown
	// to the approving admin. Bounded by maxJoinNoteLen on receipt so
	// a hostile requester can't push a megabyte into the admin's
	// pending list.
	Note string `json:"note,omitempty"`

	// PoW is the requester's proof of work, when the group asks for one.
	// Absent from requests by older builds, which the responder answers
	// with a challenge rather than a refusal — so an un-upgraded joiner
	// hitting a group that requires work gets one extra round trip and
	// then fails honestly, instead of being silently dropped.
	PoW JoinPoW `json:"pow,omitempty"`

	// AskAgain marks a DELIBERATE retry after a declined request (the UI's
	// "ask again" button). A plain re-ask from a denied PK stays terminal —
	// that is what keeps a refused requester from refilling the approval
	// queue by polling — but an explicit ask-again replaces the denied
	// record with a fresh pending one, after paying the same PoW and
	// rate-limit gates as any first request. Old responders ignore the
	// field and simply answer denied, which the requester surfaces
	// honestly.
	AskAgain bool `json:"ask_again,omitempty"`

	// Probe asks the group to DESCRIBE itself and decide nothing: no
	// roster change, no queue entry, no notification. Answered with
	// JoinStatusInfo.
	//
	// This is what makes a short skychat://<pk>/<group-id> address
	// actionable — the sender cannot know the group's kind, admission
	// policy or feed port until someone tells it, and it must be able to
	// ask without that ask being read as "let me in". A build that
	// predates this field ignores it and answers the request as a real
	// join, so the requester only ever sends a probe to the well-known
	// probe port, which no older build listens on at all.
	Probe bool `json:"probe,omitempty"`

	TS time.Time `json:"ts"`
}

JoinRequestMsg is the frame a prospective member writes to the group's relay listener.

The PK field is named requester_pk, NOT sender_pk — see the package comment; that naming is what makes an unpatched owner reject the frame cleanly instead of publishing it as chat. It is carried at all only as a cross-check against the transport identity.

func NewJoinRequest

func NewJoinRequest(groupID string, requester cipher.PubKey, note string, bits uint8) JoinRequestMsg

NewJoinRequest builds a well-formed request frame, solving the group's proof of work when one is asked for.

Solving here — on the requester's own goroutine, before the dial — is what makes the cost land on the party creating identities. bits comes from the invite link or from a challenge; zero means the group asks for nothing and the call is free.

type JoinResponseMsg

type JoinResponseMsg struct {
	Kind    string     `json:"kind"`
	GroupID string     `json:"group_id"`
	Status  JoinStatus `json:"status"`

	// Name lets the joiner display the real group name rather than
	// whatever the invite link said, which may be stale.
	Name string `json:"name,omitempty"`

	// GroupKind is the group's type as the group itself reports it —
	// authoritative over whatever the invite link claimed. Named
	// GroupKind rather than Kind because Kind above is the frame
	// discriminator.
	//
	// This is what stops a channel from being joined as an ordinary
	// public group: both are ModePublic, so without it a member's
	// composer would come up unlocked on a feed nobody else will render
	// their leaves from. Empty from a responder predating channels, in
	// which case the joiner falls back to the invite and then to Mode.
	GroupKind Kind `json:"group_kind,omitempty"`

	// AESKey is the group key, present iff Status is admitted AND the
	// group is encrypted. This is the delivery channel that replaced
	// putting the key in the invite link: a forwarded link is inert
	// until an admin approves, and the owner stays a live distributor
	// so the key can be rotated later rather than being frozen into
	// links already in circulation.
	AESKey []byte `json:"aes_key,omitempty"`

	// KeyEpoch numbers the key above. Carried so a joiner starts out
	// agreeing with the group about which generation it holds — without
	// it a joiner would sit at epoch 0 and treat the group's next
	// rotation as if it were the first, which is harmless for reading but
	// makes the epoch meaningless as a diagnostic.
	//
	// Only the CURRENT key is handed over. A new member cannot read
	// history published under retired keys, which is the same forward-only
	// rule rotation itself follows: admission grants access from now on,
	// not retroactively.
	KeyEpoch uint64 `json:"key_epoch,omitempty"`

	// Members / Admins seed the joiner's roster. Sent on admission
	// only — a pending or refused requester learns nothing about who
	// is in the group.
	Members []cipher.PubKey `json:"members,omitempty"`
	Admins  []cipher.PubKey `json:"admins,omitempty"`

	// Muted / ReadOnly seed the moderation state so a joiner who is
	// admitted into a quieted room sees the correct composer state
	// immediately rather than after the first mod/ leaf lands.
	Muted    []cipher.PubKey `json:"muted,omitempty"`
	ReadOnly bool            `json:"read_only,omitempty"`

	// PeerBackfillDisabled seeds the group's backfill policy for the same
	// reason: a joiner that assumed the default would start mirroring
	// other members' leaves in a group whose admins had turned that off,
	// and would only stop once the first mod/ leaf corrected it.
	PeerBackfillDisabled bool `json:"peer_backfill_disabled,omitempty"`

	// PoWBits is the difficulty this group requires, sent with a
	// challenge so a requester holding a stale invite learns the current
	// price rather than guessing.
	PoWBits uint8 `json:"pow_bits,omitempty"`

	// RetryAfterSec is how long a throttled requester should wait. Advisory
	// — the requester's own retry timer is the floor — but it lets a UI say
	// something better than "try again sometime".
	RetryAfterSec int `json:"retry_after_sec,omitempty"`

	// Reason is operator-facing text for the non-admitted cases.
	Reason string `json:"reason,omitempty"`

	// Port is the group's CXO feed port — Record.Port. Without it a
	// short address cannot be turned into a join at all: the admission
	// listener is at Port+1 and Port is allocated at random per group.
	Port uint16 `json:"port,omitempty"`

	// Policy is the group's admission rule, so a UI can offer "Join" or
	// "Send request" before spending a round trip to find out which it
	// was. Derived, not stored — see Record.JoinPolicy.
	Policy JoinPolicy `json:"policy,omitempty"`

	// PriceHint is free text a group may state about what admission
	// costs ("5 SKY", "5 SKY / month"). Reserved for paid channels,
	// always empty today.
	//
	// A hint, and named as one: it is unverified text from a host that
	// wants members, so it is display-only context and must never be
	// treated as terms. A real payment flow will carry its own verified
	// amounts; this field exists so introducing one does not require a
	// wire change, and so a UI has somewhere to put the number. Bounded
	// by maxPriceHintLen on receipt.
	PriceHint string `json:"price_hint,omitempty"`

	// Avatar / AvatarMime are the group's picture, mirroring
	// Record.Avatar. Describe-only like Port and Policy above, and for a
	// harder reason than "not needed": an admission response also carries
	// the full Members list, and members + a 32 KB image could overrun the
	// single 64 KB frame both answers ride. A describe answer carries no
	// roster, so here the arithmetic always holds (see
	// profile.MaxAvatarBytes). A fresh joiner picks the picture up from
	// its first metadata refresh instead.
	Avatar     []byte `json:"avatar,omitempty"`
	AvatarMime string `json:"avatar_mime,omitempty"`
}

JoinResponseMsg is the group's answer, written back on the same stream. On admission it doubles as the roster + key bootstrap, so the joiner converges immediately instead of waiting for the first gossip leaf to arrive.

func SendJoinRequest

func SendJoinRequest(ctx context.Context, dmsgC *dmsg.Client, groupID string, ownerPK cipher.PubKey, port uint16, requester cipher.PubKey, note string) (JoinResponseMsg, error)

SendJoinRequest is the requester side: dial the group's relay listener, write a join request, read the answer.

Transport preference mirrors SubmitToOwner — skynet first so groups work over arbitrary skywire transports, dmsg as the bootstrap and recovery fallback. Unlike SubmitToOwner this is called on a session that may not exist yet, so it takes the coordinates explicitly rather than reading them off a Record.

A nil dmsg client with no reachable skynet networker yields ErrJoinNoResponse rather than a nil-pointer panic, because this is reachable from the standalone build where DmsgC is legitimately nil.

func SendJoinRequestAny

func SendJoinRequestAny(ctx context.Context, dmsgC *dmsg.Client, groupID string, candidates []cipher.PubKey, port uint16, requester cipher.PubKey, note string, bits uint8, askAgain bool) (JoinResponseMsg, error)

SendJoinRequestAny asks every candidate admin, staggered, and returns the first answer that is an actual decision about the requester.

Answers that are about the RESPONDER rather than the requester (JoinStatusUnavailable — demoted, or doesn't hold the group) and candidates that don't answer at all are passed over: the next name gets its turn. A group is joinable as long as one admin in the invite is alive and still authorized.

Error precedence when nobody decides:

  • ErrJoinNoResponse if any candidate took the frame and stayed silent. That is the fingerprint of a build predating admission control, and the caller's fallback to the legacy direct-subscribe join is the right response — it has to outrank the others or a group with one old founder and one unreachable admin would stop working.
  • ErrJoinNoAdmin if some candidate answered "not mine to rule on". The group exists and is reachable; nobody present can admit.
  • otherwise the first transport error, verbatim.

func SendJoinRequestPoW

func SendJoinRequestPoW(ctx context.Context, dmsgC *dmsg.Client, groupID string, ownerPK cipher.PubKey, port uint16, requester cipher.PubKey, note string, bits uint8) (JoinResponseMsg, error)

SendJoinRequestPoW is SendJoinRequest with the difficulty the requester already knows about — from the invite link — so the common case is a single round trip.

type JoinStatus

type JoinStatus string

JoinStatus is the outcome the group reports for a join request.

const (
	// JoinStatusAdmitted — the PK now holds an allowlist seat and may
	// subscribe. For an encrypted group the response also carries the
	// AES key.
	JoinStatusAdmitted JoinStatus = "admitted"

	// JoinStatusPending — queued for an admin. The requester re-asks
	// on a timer until the answer changes.
	JoinStatusPending JoinStatus = "pending"

	// JoinStatusDenied — an admin refused. Terminal; the requester
	// stops asking.
	JoinStatusDenied JoinStatus = "denied"

	// JoinStatusBanned — the PK is barred. Terminal, and deliberately
	// distinguished from denied so the UI can say which happened.
	JoinStatusBanned JoinStatus = "banned"

	// JoinStatusUnavailable — "not my decision to make, ask someone
	// else". Answered by a visor that holds no roster authority on the
	// group, or no longer holds the group at all. It is NOT a refusal:
	// the requester moves to the next admin the invite named rather
	// than treating the group as closed.
	//
	// This status exists because a multi-admin invite means a joiner can
	// legitimately dial a PK that has since been demoted or has left. If
	// that answered "denied", one stale name in a link would sink a join
	// the group's other admins would happily have approved.
	//
	// Only ever sent to a requester that asked more than one PK — see
	// handleJoinRequest for why an older joiner never sees it.
	JoinStatusUnavailable JoinStatus = "unavailable"

	// JoinStatusChallenge — "pay first". The group wants a proof of work
	// the request didn't carry, or carried too weakly, and the response
	// says how many bits it wants. The requester solves and re-asks
	// immediately; this is a round trip, not a refusal.
	//
	// Distinct from unavailable because the requester must ACT rather than
	// move on to another admin: every admin will ask the same, and moving
	// on would spend the fan-out looking for a laxer one.
	JoinStatusChallenge JoinStatus = "challenge"

	// JoinStatusThrottled — the group is absorbing requests slower than
	// they are arriving. Retryable, and deliberately cheap to produce: a
	// throttled request is never stored, so a flood costs the admin one
	// hash and one frame rather than a row and a notification.
	JoinStatusThrottled JoinStatus = "throttled"

	// JoinStatusInfo — the answer to a DESCRIBE (JoinRequestMsg.Probe):
	// "here is what this group is". Carries the group's kind, admission
	// policy, feed port and name, and grants nothing.
	//
	// A status of its own rather than a separate frame kind so the whole
	// existing round trip — codec, deadlines, identity check — is reused.
	// It is deliberately NOT a decision (see IsDecision) and NOT terminal:
	// nothing in the join state machine may ever act on it, because a
	// describe answer says nothing about whether the asker would be let
	// in. See probe.go.
	JoinStatusInfo JoinStatus = "info"
)

func (JoinStatus) IsDecision

func (s JoinStatus) IsDecision() bool

IsDecision reports whether this status is an answer about the requester ("you're in", "wait", "no") as opposed to an answer about the responder ("I can't rule on this"). The fan-out in SendJoinRequestAny keeps asking until it gets a decision.

func (JoinStatus) IsTerminal

func (s JoinStatus) IsTerminal() bool

IsTerminal reports whether the requester should stop re-asking.

func (JoinStatus) NeedsWork

func (s JoinStatus) NeedsWork() bool

NeedsWork reports whether the group answered "solve a proof of work and come back". Handled by the requester itself rather than by moving on to the next admin — see JoinStatusChallenge.

type KeyMutation

type KeyMutation struct {
	GroupID uuid.UUID `json:"group_id"`

	// Epoch is the new key's generation, always greater than the epoch
	// the issuer held when it rotated.
	Epoch uint64 `json:"epoch"`

	// Wraps is one entry per recipient. Order is not significant: the
	// canonical bytes sort by recipient, so a reordered wire copy still
	// verifies.
	Wraps []KeyWrap `json:"wraps"`

	ParentSeq uint64        `json:"parent_seq"`
	IssuedAt  time.Time     `json:"issued_at"`
	IssuerPK  cipher.PubKey `json:"issuer_pk"`
	Signature cipher.Sig    `json:"signature"`
}

KeyMutation is one signed key rotation: a new epoch plus the sealed copies of its key.

Same envelope shape as the other three families — GroupID, IssuedAt, IssuerPK, Signature, and a ParentSeq that stays present-but-unused for the reason given in replay_guard.go.

func UnmarshalKey

func UnmarshalKey(data []byte) (KeyMutation, error)

UnmarshalKey decodes a JSON wire envelope into a KeyMutation and verifies the signature.

func (KeyMutation) Ephemeral

func (m KeyMutation) Ephemeral() bool

Ephemeral reports whether this rotation used a throwaway wrap key — i.e. whether a later compromise of the issuer's identity key can still open it. False for a leaf issued by a build that predates ephemeral wraps, which is the one case where it can.

func (KeyMutation) Recipients

func (m KeyMutation) Recipients() []cipher.PubKey

Recipients returns the PKs this rotation was addressed to, in wire order. Empty for a blinded rotation, which is the point of blinding — only the issuer knows the list, and it knows it from the roster it passed in. Operator-facing, and used by tests over legacy leaves.

type KeyState

type KeyState struct {
	Epoch   uint64
	Key     []byte
	KeyRing []GroupKey
}

KeyState is a snapshot of a record's key material, the shape the session hands to the Manager for persistence. Parallel to ModState.

type KeyWrap

type KeyWrap struct {
	// RecipientPK named the member this copy is for, in the clear. Left
	// ZERO by every rotation this build issues — see the "Who the wraps
	// are addressed to" section above; Tag replaces it. Still read on
	// receive, because pre-blinding leaves live in feeds forever and
	// their recipient has no other way to find its wrap.
	//
	// Kept in the canonical bytes unconditionally (zero or not) so those
	// old signatures still verify byte for byte.
	RecipientPK cipher.PubKey `json:"recipient_pk"`

	// Tag identifies the recipient without naming it: a digest over the
	// same ECDH secret that seals this wrap, which only the holder of the
	// recipient secret key can recompute. Absent on pre-blinding leaves.
	//
	// Signed, like EphPK and for the same reason — an attacker who could
	// rewrite tags could make a member fail to find its own wrap and fall
	// silently off the key schedule.
	Tag []byte `json:"tag,omitempty"`

	// Sealed is [12-byte nonce | ChaCha20-Poly1305 ciphertext + tag]
	// over the 32-byte group key, under the ECDH secret shared by
	// EphPK's secret half and this recipient.
	Sealed []byte `json:"sealed"`

	// EphPK is the public half of the throwaway keypair this rotation
	// sealed with; its secret half was discarded the moment the leaf was
	// built. Same value on every wrap in one mutation — the wraps differ
	// because the RECIPIENT half of the ECDH differs.
	//
	// Zero on rotations from builds that predate ephemeral wraps, which
	// is why it is omitempty and why openGroupKey still accepts a sender
	// PK from the caller: a legacy leaf resolves to the issuer's identity
	// PK, and canonicalBytesKey folds this field in only when it is set
	// so those old signatures keep verifying byte for byte.
	EphPK cipher.PubKey `json:"eph_pk,omitempty"`
}

KeyWrap is the group key sealed for exactly one recipient.

type Kind

type Kind string

Kind is the user-facing group type — the one switch an operator picks at create time. It determines admission policy, who may publish, and — through modeForKind — payload encryption.

const (
	// KindPublic — open admission, plaintext bodies, everyone posts.
	KindPublic Kind = "public"

	// KindPrivate — admin-approved admission, encrypted bodies.
	KindPrivate Kind = "private"

	// KindChannel — broadcast: open admission like a public group, but
	// only admins may publish. Plaintext for the same reason a public
	// group is: admission is open, so the key would go to anyone who
	// asked and would protect nothing.
	//
	// Unlike the ReadOnly flag — which is a reversible "everyone quiet
	// now" an admin toggles — a channel is admins-only permanently, as
	// a property of the group rather than of its current moderation
	// state. That distinction is why PostPolicy consults Kind before
	// ReadOnly, and why the reader-side gate applies it to leaves of
	// every age rather than forward-only.
	KindChannel Kind = "channel"
)

func (Kind) IsValid

func (k Kind) IsValid() bool

IsValid returns true for the recognized group kinds.

func (Kind) Policy

func (k Kind) Policy() JoinPolicy

Policy returns the admission rule this kind implies. Exported for callers holding a Kind without a Record — the invite/describe paths, which know what a group is before they have one.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager owns the persistent record set and live Session instances. Safe for concurrent use.

func NewManager

func NewManager(cfg ManagerConfig) (*Manager, error)

NewManager constructs a manager. Does not open any sessions; call Resume to bring up everything in the store.

func (*Manager) AddMember

func (m *Manager) AddMember(id string, pk cipher.PubKey) (Record, error)

AddMember (owner) extends the allowlist + persisted member list. Returns the updated record so the caller can re-issue the invite.

func (*Manager) ApproveJoin

func (m *Manager) ApproveJoin(id string, pk cipher.PubKey) (Record, error)

ApproveJoin admits a queued requester. Idempotent: approving an already-admitted PK returns the current record.

The requester is NOT notified — they have no allowlist seat yet, so there is no channel to reach them on. They learn on their next poll, within pendingJoinRetryInterval. Approving a previously-denied request works, but that requester has stopped polling, so they see it only when they ask again.

func (*Manager) AskAgain

func (m *Manager) AskAgain(id, note string) (Record, error)

AskAgain re-submits a join request an admin has DENIED — the UI's "ask again" button. Everything is derived from the stored record, so callers pass only the group id (no invite re-paste). The request goes out with the deliberate ask-again flag: the group replaces the denied record with a fresh pending one after the same PoW and rate-limit gates as a first request (see JoinRequestMsg.AskAgain). Against an admin running an older build the flag is ignored and the denial is surfaced honestly.

func (*Manager) BanMember

func (m *Manager) BanMember(id string, pk cipher.PubKey) (Record, error)

BanMember bars a peer: removes them from the roster (taking their allowlist seat, which is what makes a ban structural) and records the ban so they cannot walk back in through the join gate.

func (*Manager) BuildInvite

func (m *Manager) BuildInvite(id string) (string, error)

BuildInvite encodes an invite link for an admin-side group. Any visor that IsAdmin on the record can issue invites — the founder implicitly, and explicit Admins entries (set via PromoteAdmin). Non-admin members get an error so the legacy owner-only invariant is preserved on records whose Admins slice is still founder-only.

func (*Manager) Catalog

func (m *Manager) Catalog() ([]CatalogEntry, bool, error)

Catalog returns what THIS visor publishes: every non-terminal record with Listed set, newest first, capped at maxCatalogEntries.

Newest first because a catalog is a shop window and the thing just opened is the thing worth showing; the cap then drops the oldest rather than an arbitrary slice.

func (*Manager) Close

func (m *Manager) Close() error

Close tears down every live session and returns the first error. Does NOT close the Store — caller still owns that.

Also stops the background reconnect loop (if it was started via Resume). Idempotent: a second Close is a no-op.

func (*Manager) Create

func (m *Manager) Create(name string, kind Kind, initialMembers []cipher.PubKey, opts ...CreateOption) (Record, error)

Create constructs a new owner-side group, persists it, and opens the publisher. Returns the persisted Record (with ID + Port + key populated) so the caller can build the invite link.

func (*Manager) Delete

func (m *Manager) Delete(id string) error

Delete tears down an admin-side group and marks it revoked. Only visors that IsAdmin on the record may delete; non-admin members should call Leave instead. Returns an error for the wrong-role case so a typo doesn't silently revoke a group that another admin still owns.

func (*Manager) DemoteAdmin

func (m *Manager) DemoteAdmin(id string, pk cipher.PubKey) (Record, error)

DemoteAdmin removes pk from the group's Admins set. Refuses to demote the founder (r.OwnerPK) — the founder is the immutable recovery anchor and demoting them would lock the group out of roster recovery if every other admin's visor went offline. Other admins MAY demote each other (no protection beyond the founder rule); the assumption is that admins trust each other by virtue of having been promoted.

Idempotent on a PK that wasn't an admin to begin with — returns the current record without mutating storage. Errors only on store failure, the founder-demote attempt, and the only-admins-can-demote gate.

func (*Manager) DenyJoin

func (m *Manager) DenyJoin(id string, pk cipher.PubKey) error

DenyJoin declines a queued request. The requester is told on its next poll and then stops asking.

func (*Manager) FetchCatalog

func (m *Manager) FetchCatalog(ctx context.Context, host cipher.PubKey) ([]CatalogEntry, bool, error)

FetchCatalog asks host what it publishes.

Every entry's Address is built here from the host PK we dialed plus the entry's own ID, never taken from the wire: the transport authenticates the host, so an address derived from it cannot point somewhere else, whereas a host-supplied string could advertise a channel it does not own. Same reason Kind is cross-checked against Mode on the join path.

func (*Manager) FetchProfile

func (m *Manager) FetchProfile(ctx context.Context, host cipher.PubKey) (profile.Profile, error)

FetchProfile asks host who it is.

The answer is re-validated through profile.Normalize before it is returned, exactly as a locally-set one is: this is untrusted display text and an untrusted image from a stranger, and the receiving side is the only place that can enforce the caps. A host that sends an oversized avatar loses the avatar, not the name — the same partial-salvage rule the store applies to a hand-edited file.

Answers locally without a round trip when host is this visor.

func (*Manager) FileKeys

func (m *Manager) FileKeys(groupID, fileID string) (seal []byte, open [][]byte, epoch uint64, err error)

FileKeys resolves the attachment keys for a group this visor is in. A plaintext group yields a nil seal key and no error (see Record.FileKeys).

func (*Manager) Get

func (m *Manager) Get(id string) (Record, bool, error)

Get returns the persisted record for an ID.

func (*Manager) IsSubscriberAlive

func (m *Manager) IsSubscriberAlive(id string) bool

IsSubscriberAlive reports the live subscriber health for the group id, surfaced by the chat-app's /status endpoint as the per-group `subscriber_alive` field. Returns true for owner-role sessions (no subscriber) and for member-role sessions whose most recent Connect() succeeded. Returns false for member-role sessions sitting at StatusPending or when no live session exists for the id (e.g. the record was left/revoked).

func (*Manager) Join

func (m *Manager) Join(inv Invite) (Record, error)

Join accepts an invite link and asks the group for admission.

Thin wrapper over RequestJoin, which owns the whole flow: ask, act on the answer, and fall back to the legacy direct-subscribe path when the group runs a build without admission control. Kept as a named method because it is the API every caller (visor RPC, wasm, CLI) already speaks.

Pre-admission-control this method persisted a record and dialed the owner's publisher immediately, which only worked if an admin had already run AddMember for this PK out of band — the invite link alone never granted access. See RequestJoin for what replaced that.

func (*Manager) JoinByAddress

func (m *Manager) JoinByAddress(ctx context.Context, host cipher.PubKey, gid string) (Record, error)

JoinByAddress joins the group a short address names: probe for what it is, then drive the ordinary invite-based join with the answer.

Idempotent through the same path a link-based join is — RequestJoin returns the existing record for a group we are already active in.

func (*Manager) Leave

func (m *Manager) Leave(id string) error

Leave (member) or Delete (owner): both tear down the session and move the record to a terminal status. Idempotent on a missing or already-terminal record.

func (*Manager) List

func (m *Manager) List() ([]Record, error)

List returns every persisted record.

func (*Manager) LocalProfile

func (m *Manager) LocalProfile() (profile.Profile, error)

LocalProfile returns what this visor publishes about itself, so an operator can see their own listing without a round trip — the same courtesy Catalog gets for a zero host.

func (*Manager) MarkMessageDelivered

func (m *Manager) MarkMessageDelivered(groupID string, ts time.Time)

MarkMessageDelivered records that a message with the given timestamp reached the inbox for the named group. Belt-and-suspenders to keep LastMessageAt fresh independent of the wrapped-handler chain in openLocked: any subsystem with a Manager reference can call this after delivering a message externally (e.g. the visor's groupInbox when it drains a SSE push) so the persisted last_message_at stays consistent with what's actually flowing into the inbox.

Idempotent and best-effort: errors are swallowed at debug level because a failure here is purely cosmetic — the message still reached its consumer; only the operator-visible lag indicator is affected.

func (*Manager) MuteMember

func (m *Manager) MuteMember(id string, pk cipher.PubKey) (Record, error)

MuteMember restricts a peer from posting while leaving their read access intact. Forward-only: their existing messages stay visible.

func (*Manager) PeerLiveness

func (m *Manager) PeerLiveness(id string) map[cipher.PubKey]time.Time

PeerLiveness returns the per-peer last-inbound timestamps for the given group, keyed by peer PK. Empty map if no live session, or for owner-role sessions that don't follow peer feeds. Zero time for a peer indicates the peerSub has never observed an inbound (either peerSub is connecting, the peer is silent, or — until #2620 — the legacy s.sub subscriber hasn't covered this peer's traffic).

Used by Visor.GroupGet/GroupList to surface per-peer telemetry so operators can identify a single silent peer in an otherwise healthy group, rather than chasing the session-level SubscriberAlive=true while one member's messages quietly never arrive.

func (*Manager) PeerUpdateCount

func (m *Manager) PeerUpdateCount(id string) map[cipher.PubKey]uint64

PeerUpdateCount returns the per-peer count of OnUpdate-callback invocations observed by Session.makePeerOnUpdate for the given group. Parallel to PeerLiveness — same shape, same lifetime, same nil-on-no-session graceful degradation. Empty map for owner-role sessions that don't follow peer feeds.

Used by Visor.GroupGet / GroupList to surface the count alongside PeerLastInbound so operators can localize drops across the receive cascade.

func (*Manager) PendingJoins

func (m *Manager) PendingJoins(id string) ([]JoinRequest, error)

PendingJoins returns the join-request queue for a group, newest first. Includes decided entries so the UI can show history; callers wanting only actionable items filter on IsPending.

func (*Manager) ProbeGroup

func (m *Manager) ProbeGroup(ctx context.Context, host cipher.PubKey, gid string) (GroupDescriptor, error)

ProbeGroup asks host what the group with id gid is.

Answers locally without a round trip when this visor already holds the group: the store knows everything a probe would return, and a member re-opening its own address should not depend on the host being up.

func (*Manager) PromoteAdmin

func (m *Manager) PromoteAdmin(id string, pk cipher.PubKey) (Record, error)

PromoteAdmin adds pk to the group's Admins set. Callable by any existing admin. Idempotent — promoting an already-admin returns the current record without mutating storage. The promoted PK gains roster authority (AddMember / BuildInvite / Delete / promote+demote) from the moment this call returns successfully on this visor; other visors learn of the change through the next out-of-band roster gossip (admin-mirror PR adds the on-feed channel).

Note: in the federated send architecture, the promoted PK does not need to already be a Member — promotion is a roster-authority grant, not a membership change. Practically, an admin who isn't also a member is unusual; callers should treat that as a separate AddMember step where they want it.

func (*Manager) RefreshMeta

func (m *Manager) RefreshMeta(ctx context.Context, id string) (Record, bool, error)

RefreshMeta re-reads a member-side record's display metadata from the founding visor and persists what changed, reporting whether anything did.

Best-effort by design: an unreachable founder means nothing changed, not failure — the caller fires this when a group is opened, and "the founder is asleep" must not turn opening a chat into an error. Only local problems (no record, a store write failing) surface as errors. On the founder's own visor and on terminated records it is a no-op.

func (*Manager) RemoveMember

func (m *Manager) RemoveMember(id string, pk cipher.PubKey) (Record, error)

RemoveMember evicts a peer from the roster — the kick the gossip protocol has always defined (RosterOpRemove) but that nothing called.

A kick is not a ban: the peer may ask to rejoin, and in an open group will be readmitted immediately. Use BanMember when the intent is that they stay out.

func (*Manager) Rename

func (m *Manager) Rename(id, name string) (Record, error)

Rename changes a group's display name.

Founder-only: members hold mirrors of the founder's record, and a mirror that renames itself is overwritten by its next RefreshMeta. Refusing the write is kinder than letting it silently un-happen.

func (*Manager) ReplayHistory

func (m *Manager) ReplayHistory(id string)

ReplayHistory pumps the last resumeReplayMessageCap messages of one resumed group back through the registered message handler, on demand. Resume() already does this once per group at startup; this exported entry point lets a consumer re-pull after a subscribe has had time to sync peer CXO feeds — the wasm visor uses it to backfill group history into its in-memory buffer after a Join (its store doesn't survive a tab reload, so there is nothing to Resume; the history arrives over the network and this walks it into view). Safe to call repeatedly: the walk dedups within a call, and consumers are expected to dedup replayed vs. live messages on their side (the visor inbox and the wasm buffer both do). No-op for an unknown/closed session.

func (*Manager) RequestJoin

func (m *Manager) RequestJoin(inv Invite, note string) (Record, error)

func (*Manager) RequestJoinAgain

func (m *Manager) RequestJoinAgain(inv Invite, note string) (Record, error)

RequestJoin asks a group for admission and acts on the answer. The member-side entry point that Join delegates to.

Outcomes:

  • admitted → record persisted active, session opened and connected
  • pending → record persisted awaiting-approval, NO session (we have no allowlist seat, so a subscribe would just be refused); the pending-join loop re-asks until the answer changes
  • denied / banned → terminal error, nothing persisted
  • no response → the group runs a build without admission control; fall back to the legacy "persist and try to subscribe" path so existing groups keep working

The request goes to every admin the invite names, not just the founder — see Invite.Admins for why that stopped being good enough. RequestJoinAgain is RequestJoin for a requester an admin has DECLINED: a deliberate retry (the UI's "ask again" button) that asks the group to replace the denied record with a fresh pending one. It pays the same PoW and rate-limit gates as a first request, so a declined PK cannot spam the approval queue any faster than a new one; against an admin running an older build the flag is ignored and the answer is simply denied again.

func (*Manager) Resume

func (m *Manager) Resume() error

Resume walks the store and reopens every non-terminal session. Terminal records (left / revoked / denied / banned) are skipped; the operator can `group join <invite>` again to bring them back.

Awaiting-approval records are skipped too, but for the opposite reason: they are not finished, they have not started. Without an allowlist seat a subscribe would just be refused, so the reconnect loop's retryPendingJoins re-asks instead and opens the session the moment an admin approves.

On reopen, the most recent resumeReplayMessageCap messages per group are replayed through the registered handler so consumers (visor inbox → group listen) see immediate context after a visor restart, rather than an empty feed until the next live message arrives. The replay drives the same handler path as live messages, so order and shape match what a steady-state listener would see.

func (*Manager) RotateKey

func (m *Manager) RotateKey(id string) (Record, error)

RotateKey mints a new group key and distributes it to every current member except this visor, each copy sealed to that member's PK.

Admin-only and encrypted-groups-only. Exposed for the operator-driven case — a key believed leaked, an operator tidying up after an out-of-band departure — while eviction rotates automatically through rotateAfterEviction.

func (*Manager) SendToGroup

func (m *Manager) SendToGroup(ctx context.Context, id, text string) error

SendToGroup publishes a message into the named group's CXO feed. Dispatches by role:

  • Owner: writes directly to the publisher via Session.Send.
  • Member: opens a dmsg stream to the owner's relay listener and submits the message; the owner re-publishes into the feed with sender attribution preserved.

In both cases the sender's own subscriber sees the resulting feed leaf and renders it to the inbox, so the UX is "type, hit Send, see your message in history" regardless of role.

func (*Manager) SetAvatar

func (m *Manager) SetAvatar(id string, avatar []byte) (Record, error)

SetAvatar sets or clears (empty bytes) a group's picture. Founder-only, same reasoning as Rename. The bytes are validated as a real image within the profile caps regardless of caller — this is the last gate before the store, and what is in the store goes out on the wire.

func (*Manager) SetJoinPoW

func (m *Manager) SetJoinPoW(id string, bits uint8) (Record, error)

SetJoinPoW sets how much proof of work this group asks of a join request, in leading zero bits. Zero turns the price off entirely.

Admin-only. Local policy rather than gossiped state — see Record.JoinPoWBits for why converging it would buy nothing — so it takes effect on requests THIS visor answers, and on invites this visor mints. Raising it is the lever for a group under active flood; every link already in circulation understates the price, and those joiners get one extra round trip (a challenge) rather than a refusal.

func (*Manager) SetJoinRequestNotifier

func (m *Manager) SetJoinRequestNotifier(f func(JoinRequest))

SetJoinRequestNotifier installs a callback fired when a NEW join request lands on this visor's approval queue. Used by the app layer to raise an SSE event and an OS notification. Not fired for a re-ask of a request already queued — the requester polls, and an admin doesn't need a notification every 30s for the same person.

func (*Manager) SetListed

func (m *Manager) SetListed(id string, listed bool) (Record, error)

SetListed opts a group into or out of this visor's catalog.

Admin-only, and deliberately NOT gossiped — see Record.Listed. It is a local hosting decision, so it writes the record directly rather than going through moderate(): there is no signed state for other members to converge on, and pretending otherwise would imply an admin can un-list a copy somebody else is advertising.

func (*Manager) SetMessageHandler

func (m *Manager) SetMessageHandler(h MessageHandler)

SetMessageHandler installs the inbound callback. Propagated to every live Session.

func (*Manager) SetPeerBackfill

func (m *Manager) SetPeerBackfill(id string, enabled bool) (Record, error)

SetPeerBackfill decides whether any online member may serve this group's history and messages to a joiner, or only admins.

Admin-only and group-wide, converging to every member through the same signed gossip as the read-only toggle. Turning it ON is what makes a group readable while its admins are offline; turning it OFF restores the admins-only topology, at the cost of the group going dark whenever no admin is up. See Record.PeerBackfillDisabled for the full trade.

The live session's subscription set is re-evaluated either way, so the change takes effect now rather than at the next restart.

Refused outright on a channel. PeerBackfillEnabled is unconditionally false there (a subscriber has nothing of its own to serve), so accepting the call would gossip a signed state change to every member and change nothing — an admin would be told the setting took, and it would not have. An error is the honest answer.

func (*Manager) SetProfileProvider

func (m *Manager) SetProfileProvider(p ProfileProvider)

SetProfileProvider installs the source the describe port answers profile requests from. A nil provider (the default) answers empty.

func (*Manager) SetReadOnly

func (m *Manager) SetReadOnly(id string, readOnly bool) (Record, error)

SetReadOnly suspends (or resumes) posting for every non-admin — the reversible "quiet the room" control.

func (*Manager) StartProbeListener

func (m *Manager) StartProbeListener()

StartProbeListener binds the well-known describe port and serves it until StopProbeListener (or Close) is called. Idempotent.

Best effort by design, matching every other listener in this package: a failed bind disables short-address joins for this run and logs it, rather than failing group chat as a whole. The invite-link path is unaffected, so the degradation is "addresses need a link instead", not "groups are broken".

func (*Manager) StopProbeListener

func (m *Manager) StopProbeListener()

StopProbeListener tears the describe listeners down. Idempotent.

func (*Manager) UnbanMember

func (m *Manager) UnbanMember(id string, pk cipher.PubKey) (Record, error)

UnbanMember lifts a ban. Does not re-admit: the peer must ask to join again, so the group's admission policy applies to their return the same way it applied to their arrival.

func (*Manager) UnmuteMember

func (m *Manager) UnmuteMember(id string, pk cipher.PubKey) (Record, error)

UnmuteMember lifts an individual posting restriction.

func (*Manager) Unsend

func (m *Manager) Unsend(id string, tsNano int64) error

Unsend removes a message the local visor published to group id, identified by its UnixNano timestamp. Only the sender can unsend their own message (a publisher controls only its own feed); the deletion propagates to subscribers via CXO snapshot-diff replication. Returns an error if there is no live session for the group.

type ManagerConfig

type ManagerConfig struct {
	Store   *Store
	DmsgC   *dmsg.Client
	MyPK    cipher.PubKey
	MySK    cipher.SecKey
	DataDir string
	Logger  *logging.Logger

	// InMemoryDB runs every session's CXO tree in memory (no DataDir /
	// filesystem). Required for the browser (js/wasm) visor. When set, DataDir
	// may be empty. Forwarded to each group.Config the Manager opens.
	InMemoryDB bool

	// Profile is what this visor publishes about itself on the describe
	// port — a display name and a small avatar. Optional: a nil provider
	// answers every profile request empty, which is what a visor whose
	// operator has set nothing should say. See profile.go.
	Profile ProfileProvider

	// HeartbeatInterval, when > 0, makes every owner-role session
	// opened by this Manager emit a periodic no-op heartbeat probe.
	// Members observe these to detect a silently-stalled CXO
	// subscriber inside ~3×interval. Zero disables (default for
	// callers that don't set it; for visor production set to 30s).
	HeartbeatInterval time.Duration

	// PeerFanout caps how many non-admin peers each non-admin session on
	// this visor follows, on top of the admins. Zero takes the package
	// default; negative opts this visor out of peer backfill entirely
	// regardless of what a group's admins chose. See Config.PeerFanout.
	PeerFanout int

	// JoinBurst / JoinRefill / MaxPendingJoins tune the anti-flood gates:
	// how many join attempts a group absorbs back-to-back, how fast that
	// allowance returns, and how deep the approval queue may get. Zero
	// takes the package defaults. See joincost.go for what each one
	// actually protects.
	JoinBurst       int
	JoinRefill      time.Duration
	MaxPendingJoins int
}

ManagerConfig wires a Manager.

type Message

type Message struct {
	// SenderPK identifies which member sent this. Required.
	SenderPK cipher.PubKey `json:"sender_pk"`

	// TS is the sender's wall-clock. Used for ordering display and
	// for the per-feed last-50 history seed.
	TS time.Time `json:"ts"`

	// Text is the plaintext body for ModePublic groups. Empty for
	// ModePrivate (use Ciphertext+Nonce instead).
	Text string `json:"text,omitempty"`

	// Ciphertext + Nonce carry AES-GCM-encrypted bodies for
	// ModePrivate groups. Nonce is a fresh 12-byte value per message
	// — never reused for the same key, which is what AES-GCM
	// requires.
	Ciphertext []byte `json:"ciphertext,omitempty"`
	Nonce      []byte `json:"nonce,omitempty"`

	// Signature binds (SenderPK, TS, Text|Ciphertext|Nonce) to the
	// sender's identity via secp256k1 over the canonical-bytes
	// layout in signing.go. Verified on every inbound leaf so an
	// admin-aggregator republishing this Message verbatim cannot
	// tamper with the body or the sender claim — the worst they can
	// do is omit, which is detectable by cross-admin divergence.
	//
	// Zero value (cipher.Sig{}) means "unsigned legacy leaf" — a
	// publisher predating the admin-aggregator design. VerifyMessage
	// returns ErrLeafUnsignedLegacy for this case so the inbox path
	// can downgrade to accept-with-warning during the deprecation
	// window, then flip to strict-reject once those publishers
	// have aged out.
	//
	// JSON `omitempty` is honored when the field is exactly the
	// zero value, keeping pre-signing leaves on disk byte-identical
	// to what they were before this field was introduced.
	Signature cipher.Sig `json:"signature,omitempty"`
}

Message is the on-the-wire (well, on-the-feed) form of a group chat entry. For ModePrivate groups, the body lands in Ciphertext + Nonce instead of Text; the renderer plugs in AESKey to decrypt.

type MessageHandler

type MessageHandler func(groupID string, senderPK cipher.PubKey, msg Message)

MessageHandler is invoked on every newly-arrived group message after decryption (for private groups). Called from the subscriber's update goroutine; implementations must not block.

The senderPK is the member who published the message (extracted from the decoded Message.SenderPK field, not from the CXO leaf path, so a malicious owner who relays from another member can't spoof attribution beyond what the sender's signature already gates).

type ModOp

type ModOp uint8

ModOp is the kind of moderation mutation. Small integer for canonical-bytes compactness, same as RosterOp / AdminOp.

Numbering starts where it does (not at 1) purely for readability in logs; the domain tag — not the op value — is what keeps these from colliding with the other envelope families.

const (
	// ModOpBan bars PeerPK from the group: drops the allowlist seat,
	// removes from Members, refuses future join requests, and drops
	// any in-flight leaves. Idempotent.
	ModOpBan ModOp = 1

	// ModOpUnban lifts a ban. Does NOT re-admit — the PK must ask to
	// join again, which is the point: unban means "you may ask", not
	// "you're back in".
	ModOpUnban ModOp = 2

	// ModOpMute restricts PeerPK from posting while leaving read
	// access intact. Idempotent.
	ModOpMute ModOp = 3

	// ModOpUnmute lifts a mute. Idempotent.
	ModOpUnmute ModOp = 4

	// ModOpReadOnly suspends posting for every non-admin. Group-scope:
	// PeerPK is the zero value, which the validators permit for this
	// op alone.
	ModOpReadOnly ModOp = 5

	// ModOpReadWrite lifts read-only. Group-scope, PeerPK zero.
	ModOpReadWrite ModOp = 6

	// ModOpPeerBackfillOn allows any online member to serve the group's
	// history and messages, not just admins. Group-scope, PeerPK zero.
	// See Record.PeerBackfillDisabled for the trade an admin is making.
	ModOpPeerBackfillOn ModOp = 7

	// ModOpPeerBackfillOff restricts serving to admins only — the
	// original topology. Group-scope, PeerPK zero.
	ModOpPeerBackfillOff ModOp = 8
)

type ModState

type ModState struct {
	Banned        []cipher.PubKey
	Muted         []cipher.PubKey
	MutedSince    map[string]time.Time
	ReadOnly      bool
	ReadOnlySince time.Time
	// PeerBackfillDisabled mirrors Record.PeerBackfillDisabled — a
	// group-wide admin toggle that converges through this same envelope.
	PeerBackfillDisabled bool
}

ModState is a transferable snapshot of a group's moderation state. Bundled rather than passed as four loose arguments because every producer and consumer needs all of it, and the two "since" halves are meaningless apart from the flags they qualify.

type Mode

type Mode string

Mode selects whether messages are encrypted on the feed. It is the persisted encryption switch and the invite-link wire field; the user-facing group type is Kind, which Mode is derived from at create time (and which is derived FROM Mode for legacy records). Prefer Record.Encrypted() over comparing this directly.

const (
	// ModePublic — plaintext JSON message bodies on the feed.
	// Anyone the owner allowlists can read. Suited for semi-public
	// rooms (e.g. "operator support").
	ModePublic Mode = "public"

	// ModePrivate — AES-256-GCM-encrypted message bodies. Without
	// the key, even an allowlisted subscriber sees only ciphertext.
	// The key reaches a joiner in the approval response (see
	// JoinResponseMsg); legacy invite links carrying it still work.
	ModePrivate Mode = "private"
)

func (Mode) IsValid

func (m Mode) IsValid() bool

IsValid returns true for the two recognized modes.

type ModerationMutation

type ModerationMutation struct {
	GroupID   uuid.UUID     `json:"group_id"`
	Op        ModOp         `json:"op"`
	PeerPK    cipher.PubKey `json:"peer_pk"`
	ParentSeq uint64        `json:"parent_seq"`
	IssuedAt  time.Time     `json:"issued_at"`
	IssuerPK  cipher.PubKey `json:"issuer_pk"`
	Signature cipher.Sig    `json:"signature"`
}

ModerationMutation is one signed moderation instruction, issued by an admin and applied independently on every subscribing visor.

PeerPK is the target for peer-scoped ops and the zero value for group-scoped ones (read-only toggles). The signature covers the canonical-bytes encoding including the moderation domain tag.

func UnmarshalMod

func UnmarshalMod(data []byte) (ModerationMutation, error)

UnmarshalMod decodes a JSON wire envelope into a ModerationMutation and verifies the signature.

type PostPolicy

type PostPolicy string

PostPolicy is who may publish chat messages into a group.

const (
	// PostAll — every member may post (minus individually muted PKs).
	PostAll PostPolicy = "all"

	// PostAdminsOnly — only admins may post. Set either by the
	// group-wide ReadOnly flag (a reversible "everyone quiet now") or
	// permanently by KindChannel.
	PostAdminsOnly PostPolicy = "admins"
)

type ProfileProvider

type ProfileProvider interface {
	Load() (profile.Profile, error)
}

ProfileProvider supplies this visor's published profile to the listener.

An interface rather than the concrete *profile.Store so this package keeps depending on profile only for its data type, and so a Manager constructed without one (every test, and any caller that does not want to publish) simply answers empty.

type ProfileRequestMsg

type ProfileRequestMsg struct {
	Kind string    `json:"kind"`
	TS   time.Time `json:"ts"`
}

ProfileRequestMsg asks a visor who it is.

Carries no target public key: the answer is always about the visor being dialed. A "tell me about someone else" form would turn every visor into a directory of the people it has met, which is the opposite of a design where you learn about a peer from a human.

type ProfileResponseMsg

type ProfileResponseMsg struct {
	Kind string        `json:"kind"`
	PK   cipher.PubKey `json:"pk"`

	Name       string    `json:"name,omitempty"`
	Avatar     []byte    `json:"avatar,omitempty"`
	AvatarMime string    `json:"avatar_mime,omitempty"`
	Updated    time.Time `json:"updated,omitzero"`
}

ProfileResponseMsg is the host's answer.

type Record

type Record struct {
	// ID is the group's UUID. Used as the bbolt bucket key.
	ID string `json:"id"`

	// Name is operator-supplied free-form. Not used as an identity
	// — just for display. Set at create time; the founding visor may
	// change it later through Manager.Rename (meta.go), and everyone
	// else re-learns it from the founder — see Manager.RefreshMeta.
	Name string `json:"name"`

	// Avatar is the group's picture as raw encoded image bytes, with
	// AvatarMime naming the format as DECODED (never as declared).
	// Display-only like Name, bounded by profile.MaxAvatarBytes so it
	// always fits a probe frame, and owned the same way: the founding
	// visor sets it (Manager.SetAvatar), members mirror it. Empty means
	// no picture.
	Avatar     []byte `json:"avatar,omitempty"`
	AvatarMime string `json:"avatar_mime,omitempty"`

	// OwnerPK is the founding-creator visor's public key. Historically
	// this was the single PK with roster authority + the only PK that
	// could publish to the feed. As of the federated send change,
	// roster authority is on Admins (which always includes OwnerPK
	// implicitly), and every member publishes to their own feed. The
	// field name stays for backward compatibility with persisted
	// records + the invite-link schema; semantically it's now the
	// "founder" PK — immutable, used as the recovery anchor.
	OwnerPK cipher.PubKey `json:"owner_pk"`

	// Admins are the PKs with roster authority: add/remove members,
	// issue invites, change AES key, promote/demote other admins.
	// OwnerPK is always treated as an admin (the founder is immutable
	// admin); on-disk this slice may or may not include it explicitly
	// — IsAdmin handles either case.
	//
	// Empty/nil on legacy records — the migration path in store.Get
	// fills it with [OwnerPK] on first read so older groups inherit
	// founder-only admin authority. Operator-driven changes flow
	// through Manager.PromoteAdmin / DemoteAdmin which never permit
	// removing the founder (OwnerPK) from this list.
	Admins []cipher.PubKey `json:"admins,omitempty"`

	// AdmissionPKs are the admin PKs an invite named as able to admit us.
	// Kept ONLY so a requester whose join is queued can keep asking
	// someone other than the founder — retryPendingJoins reads it, and
	// nothing else does.
	//
	// Deliberately not merged into Admins. These PKs are an unverified
	// claim by whoever wrote the invite, and Admins is roster authority:
	// a PK in there has its signed roster/mod gossip accepted. Keeping
	// the two apart means an invite can route a join request without also
	// granting the PKs it names any power over our copy of the group. The
	// real admin set arrives in the admission response and converges
	// through gossip from then on.
	AdmissionPKs []cipher.PubKey `json:"admission_pks,omitempty"`

	// Port is the DMSG port the owner's publisher listens on.
	// Chosen by the owner at create time (not deterministic from
	// the groupID — pair-allocator-style determinism doesn't help
	// when the owner is one fixed PK; an opaque port works fine
	// because the invite link distributes it).
	Port uint16 `json:"port"`

	// Mode is public vs private. Drives whether AESKey is present
	// + whether the body is encrypted on the feed. Derived from Kind
	// at create time; kept as the persisted + invite-link field so
	// older binaries and links keep working.
	Mode Mode `json:"mode"`

	// Kind is the user-facing group type — the switch that decides
	// admission policy as well as encryption. Empty on records
	// persisted before this field existed; EnsureKind fills it from
	// Mode on read, so callers never see the zero value.
	Kind Kind `json:"kind,omitempty"`

	// Banned enumerates PKs barred from the group. A ban is strictly
	// stronger than a removal: the PK loses its allowlist seat (so it
	// can no longer read), leaves Members (so nobody subscribes to
	// its feed), is refused at the join-request gate (so it can't
	// walk back in through an open group), and its leaves are dropped
	// reader-side if any are still in flight.
	//
	// Admin-authored and gossiped, like the roster.
	Banned []cipher.PubKey `json:"banned,omitempty"`

	// Muted enumerates PKs that may read but not post ("restricted
	// users"). Unlike a ban this cannot be enforced at the transport:
	// in the federated topology a muted member still owns their own
	// feed and can physically publish to it. Enforcement is therefore
	// reader-side — every honest member drops msgs/ leaves authored
	// by a muted PK — plus a local composer lock so the muted
	// operator sees why rather than shouting into a void.
	//
	// That is the best achievable without reintroducing a central
	// relay, and it is sound against the threat that matters: the
	// mute state is admin-signed and converges everywhere, so a
	// patched client can emit bytes but cannot make anyone render
	// them.
	Muted []cipher.PubKey `json:"muted,omitempty"`

	// MutedSince records when each mute took effect, keyed by the
	// PK's hex. Mutes are FORWARD-ONLY: a restriction stops someone
	// from speaking, it does not erase what they already said. The
	// reader-side gate drops a leaf only when its timestamp is after
	// the entry here, so muting a member leaves their history intact
	// and a later unmute doesn't have to resurrect anything.
	//
	// Always written alongside a Muted entry; a missing entry means
	// zero time, i.e. effective from the beginning. Deliberately NOT
	// applied to bans — a ban means "gone", and hiding the banned
	// PK's history is the intent.
	MutedSince map[string]time.Time `json:"muted_since,omitempty"`

	// ReadOnly, when set, suspends posting for every non-admin —
	// the reversible "quiet the room" control. Same reader-side
	// enforcement model as Muted.
	ReadOnly bool `json:"read_only,omitempty"`

	// JoinPoWBits is how much proof of work this group demands with a join
	// request, in leading zero bits. Zero means none.
	//
	// Local policy, not gossiped, and that is a deliberate limitation
	// worth stating: each admin enforces its own price, and an open group
	// is only as expensive as its cheapest admin. Converging it would not
	// fix that — an admin running a patched binary can always answer for
	// free — so the cost of the extra machinery buys nothing against the
	// threat that matters, which is an outsider minting identities rather
	// than an insider undercutting the group.
	//
	// Zero on records written before this existed. Read it through
	// JoinPoWRequired(), which applies the default so an operator who has
	// never heard of this field still gets the protection.
	JoinPoWBits uint8 `json:"join_pow_bits,omitempty"`

	// JoinPoWConfigured distinguishes "an admin chose zero" from "this
	// record predates the setting". Without it, turning the price OFF
	// would be indistinguishable from never having set it, and the
	// default would silently turn itself back on.
	JoinPoWConfigured bool `json:"join_pow_configured,omitempty"`

	// PeerBackfillDisabled turns OFF serving the group's history and
	// messages from any online member, restricting it to admins only.
	//
	// An admin decision, gossiped like the other group-wide toggles. With
	// backfill enabled (the default) every member mirrors the verified
	// leaves it sees onto its own feed and non-admins follow a couple of
	// other non-admins, so a joiner can be caught up by whoever happens to
	// be online. Disabled, only admins mirror and only admins are
	// followed — the original topology, where no admin online means no
	// history for anyone.
	//
	// Stored as the NEGATIVE so the zero value means enabled: existing
	// records and newly created groups get the availability behavior
	// without a migration step. Read it through PeerBackfillEnabled()
	// rather than testing this field.
	//
	// What an admin is trading. Enabled costs storage and subscriptions on
	// every member (each holds the room's history, not just admins) and
	// widens who can be asked for it. It does NOT widen who may READ:
	// every copy is the author's own signed leaf, still encrypted for
	// private groups, and only members hold an allowlist seat. Disable it
	// for a group where members should not carry each other's history at
	// rest, and accept that the group then goes dark whenever the admins
	// are offline.
	PeerBackfillDisabled bool `json:"peer_backfill_disabled,omitempty"`

	// Listed opts this group into the visor's discovery catalog: with it
	// set, anyone who knows the HOST's public key can ask for the group
	// and be told it exists. Without it, the group is reachable only by
	// its address or an invite link, both of which require the group ID —
	// 122 bits nobody guesses.
	//
	// Opt-in, and the zero value is the private one. That direction is the
	// whole point: a catalog is the only mechanism here that turns one
	// public key into a LIST, so it must never acquire an entry because
	// somebody didn't read a checkbox. Existing records stay unlisted
	// through the upgrade for the same reason.
	//
	// Local, NOT gossiped. It says what THIS visor is willing to answer
	// questions about, which is a hosting decision rather than a property
	// of the group — two admins can legitimately differ on whether they
	// advertise the same channel, and neither should be able to publish
	// the other's copy. That also means an admin cannot un-list a channel
	// somebody else is advertising, which is honest: they never could.
	Listed bool `json:"listed,omitempty"`

	// ReadOnlySince is when the current read-only period began, and
	// exists for the same forward-only reason as MutedSince. Without
	// it, flipping a busy group to read-only would make every prior
	// message vanish from every member's view the moment the state
	// converged — the reader gate cannot otherwise tell "sent while
	// quieted" from "sent last week".
	ReadOnlySince time.Time `json:"read_only_since,omitempty"`

	// AESKey is the 32-byte AES-256-GCM key currently used to encrypt
	// outgoing messages. Set iff Mode == ModePrivate. Reaches a joiner in
	// the admission response (it no longer travels in the invite link)
	// and is replaced on every rotation.
	//
	// In memory only. The store swaps it for AESKeySealed on the way to
	// disk, so a persisted record never carries it — see store_seal.go.
	// It is still tagged (rather than json:"-") because records written
	// before sealing existed carry it, and those have to keep opening.
	AESKey []byte `json:"aes_key,omitempty"`

	// AESKeySealed is AESKey encrypted at rest under a key derived from
	// this visor's secret key. Present on disk, absent in memory: the
	// store fills one and clears the other in each direction. Never
	// populate it by hand.
	AESKeySealed []byte `json:"aes_key_sealed,omitempty"`

	// KeyEpoch numbers the generation of AESKey. Zero is the key the
	// group was created with — and the value every record written before
	// rotation existed carries, so no migration is needed.
	KeyEpoch uint64 `json:"key_epoch,omitempty"`

	// KeyIssuedAt is the IssuedAt of the KeyMutation that delivered
	// AESKey. Used to settle a same-epoch race between two admins
	// rotating at once; zero for the create-time key.
	KeyIssuedAt time.Time `json:"key_issued_at,omitempty"`

	// KeyRing holds superseded keys, newest first, so messages published
	// before a rotation still open. Rotation is forward-only: it takes
	// away what the evicted PK can read NEXT, it does not erase the
	// group's history for everyone else. See keyring.go.
	KeyRing []GroupKey `json:"key_ring,omitempty"`

	// Members enumerates every PK the owner has allowlisted. On
	// the owner side this drives Publisher.SetAllowlist; on the
	// member side it's display-only (the operator wants to know
	// who else is in the room).
	Members []cipher.PubKey `json:"members"`

	// Role tells lifecycle code whether to manage a Publisher
	// (owner) or a Subscriber (member).
	Role Role `json:"role"`

	// MutationSeen is the replay guard's state: the IssuedAt of the
	// newest gossip mutation applied per (family, target), keyed
	// "<r|a|m>:<pkHex|group>". A mutation older than its entry is
	// refused, so a replayed leaf cannot resurrect state an admin
	// already undid. See replay_guard.go for why this exists and why
	// the map is never pruned.
	MutationSeen map[string]time.Time `json:"mutation_seen,omitempty"`

	Status        Status    `json:"status"`
	CreatedAt     time.Time `json:"created_at"`
	JoinedAt      time.Time `json:"joined_at"`
	LastMessageAt time.Time `json:"last_message_at,omitempty"`
}

Record is the persisted form of a group as this visor knows it. Member visors and the owner visor store the same shape; only Role differs.

func (Record) AdmissionTargets

func (r Record) AdmissionTargets(self cipher.PubKey) []cipher.PubKey

AdmissionTargets returns the ordered PKs to send a join request to for this group: the founder, then whoever the invite named, then any admin we have since learned about. self is excluded. Used by the pending-join retry loop; the first attempt goes through Invite.AdmissionTargets because there is no Record yet.

func (Record) CanPost

func (r Record) CanPost(pk cipher.PubKey) (bool, string)

CanPost reports whether pk may publish a chat message into this group right now, and if not, a short operator-facing reason. The reason is surfaced verbatim in the UI's disabled-composer state and in the error returned to a send attempt, so it's phrased for a human.

Admins are exempt from ReadOnly (they're the ones who set it) but NOT from an explicit mute: muting an admin is unusual, but if an admin did it deliberately, silently ignoring it would be worse than honoring it. Banned outranks everything.

func (Record) DecryptionKeys

func (r Record) DecryptionKeys() [][]byte

DecryptionKeys returns every key this visor can try, current first then the ring newest-first.

Ordering is the whole optimization: steady-state traffic is encrypted under the current key, so the first attempt succeeds and the ring is never touched. Only a message from before a rotation — or from a peer that hasn't applied the rotation yet — walks further.

Trying keys in turn is sound because AES-GCM is authenticated: a wrong key fails the tag check rather than yielding plausible garbage. It also means no key-epoch hint has to ride on Message, which keeps the signed canonical byte layout in signing.go untouched — an epoch field there would have invalidated every signature already on a feed.

func (Record) Encrypted

func (r Record) Encrypted() bool

Encrypted reports whether message bodies on this group's feed are AES-GCM-sealed. The single predicate every encrypt/decrypt site should consult, so introducing a Kind whose encryption doesn't match the historical Mode mapping is a one-line change here.

func (*Record) EnsureFounderInAdmins

func (r *Record) EnsureFounderInAdmins()

EnsureFounderInAdmins normalizes the on-disk Admins slice so the founder is always explicitly present. Called by the store on read for legacy records (Admins == nil) and by manager ops that mutate Admins so the invariant holds post-mutation. Idempotent.

func (*Record) EnsureKind

func (r *Record) EnsureKind()

EnsureKind normalizes the on-disk Kind so callers never observe the zero value. Records written before Kind existed carry only Mode; their admission policy becomes whatever that Mode implied. Called by the store on read, mirroring EnsureFounderInAdmins. Idempotent.

func (Record) FileKeys

func (r Record) FileKeys(fileID string) (seal []byte, open [][]byte, err error)

FileKeys returns the key to seal a new attachment with and every key that may open an existing one, for the given file id.

seal is nil for a plaintext group — a public group hands its key to anyone who asks, so sealing an attachment with it would protect nothing and would only add a way for the file to become unreadable. The caller sends such attachments as-is.

open is ordered current-key-first, exactly like Record.DecryptionKeys, so the common case (an attachment shared under the current epoch) opens on the first try and the ring is walked only for older files.

func (Record) IsAdmin

func (r Record) IsAdmin(pk cipher.PubKey) bool

IsAdmin reports whether pk has roster authority on this group — i.e. is the founder OR is explicitly listed in Admins. Used to gate AddMember / BuildInvite / promote-demote / delete operations.

The founder check is OR'd in unconditionally so a legacy record whose Admins slice is nil (pre-migration on-disk shape) still treats the founder as an admin without needing the migration to have run first.

func (Record) IsBanned

func (r Record) IsBanned(pk cipher.PubKey) bool

IsBanned reports whether pk is barred from this group.

func (Record) IsChannel

func (r Record) IsChannel() bool

IsChannel reports whether this group is a broadcast channel — open to join, admins-only to post.

Needs no Mode fallback, unlike JoinPolicy: a record written before Kind existed cannot have been a channel, because channels did not, so an empty Kind is correctly false. That is also the safe direction — an unnormalized record reads as an ordinary group rather than silently silencing every member of one.

func (Record) IsFounder

func (r Record) IsFounder(pk cipher.PubKey) bool

IsFounder reports whether pk is the group's founder (the original creator). The founder is implicitly always an admin and cannot be demoted. Equality with OwnerPK is the source of truth.

func (Record) IsMuted

func (r Record) IsMuted(pk cipher.PubKey) bool

IsMuted reports whether pk is individually restricted from posting. Does NOT account for the group-wide ReadOnly flag — use CanPost for the composite answer.

func (Record) JoinPoWRequired

func (r Record) JoinPoWRequired() uint8

JoinPoWRequired returns the difficulty a join request must meet for this group: the admin's setting when one exists, the package default otherwise. The single predicate the admission gate consults.

func (Record) JoinPolicy

func (r Record) JoinPolicy() JoinPolicy

JoinPolicy returns the admission rule implied by this group's Kind.

func (Record) MuteEffectiveFrom

func (r Record) MuteEffectiveFrom(pk cipher.PubKey) time.Time

MuteEffectiveFrom returns when pk's mute began. Zero time for a PK that isn't muted, or for a mute recorded without a timestamp.

func (Record) PeerBackfillEnabled

func (r Record) PeerBackfillEnabled() bool

PeerBackfillEnabled reports whether any online member may serve this group's history and messages, as opposed to admins only. The single predicate every call site should consult — see PeerBackfillDisabled for why the stored field is inverted.

Always false for a channel, whatever the stored flag says. In a channel a non-admin has nothing of its own to serve — it cannot post — so mirroring the room onto its feed would buy availability for nobody while charging every subscriber the disk. It is also what the whole shape of a channel implies: subscribers read, admins publish and serve. A channel with 10,000 members would otherwise have 10,000 copies of itself.

func (Record) PostPolicy

func (r Record) PostPolicy() PostPolicy

PostPolicy returns who may currently publish into this group. ReadOnly narrows an otherwise-open group to admins only; it is a live toggle, so this is deliberately computed rather than stored. A channel is admins-only by construction, whatever ReadOnly says.

type RelayAck

type RelayAck struct {
	Ack   bool   `json:"ack"`
	MsgID string `json:"msg_id,omitempty"`
}

RelayAck is the owner's reply to a RelayMessage with MsgID set. Written on the same stream after the owner's publishAs has returned successfully — i.e. the owner has committed the leaf to the CXO feed. The member receives RelayAck as positive confirmation that the message reached the feed (not just that the relay stream accepted the bytes).

On the wire: same length-prefixed framing as RelayMessage, JSON body with {ack: true, msg_id: <echoed>}. The Ack=true sentinel distinguishes a RelayAck from any future stream-bound envelope type the relay protocol may grow.

type RelayMessage

type RelayMessage struct {
	SenderPK cipher.PubKey `json:"sender_pk"`
	Text     string        `json:"text"`
	TS       time.Time     `json:"ts"`
	MsgID    string        `json:"msg_id,omitempty"`
}

RelayMessage is the on-the-wire envelope a member sends to the owner's relay listener. The owner validates SenderPK against the group's Members allowlist, then re-publishes into the CXO feed using SenderPK so the attribution survives the relay hop.

MsgID, when set by the member, requests a RelayAck reply on the same stream after the owner's publishAs succeeds. Pre-#group- relay-ack members didn't set it; pre-#group-relay-ack owners don't reply. Both directions are backward-compatible: a missing MsgID just skips the ack roundtrip, a missing ack reply just times out cleanly on the member's read.

Note: this envelope is not encrypted on its own; for ModePrivate groups, the owner AES-GCM-seals the Text before publishing to the feed (same shape as a direct owner Send). The dmsg session gives transport encryption between the member and the owner.

type Role

type Role string

Role distinguishes the operator's relationship to this group.

const (
	// RoleOwner — this visor publishes the feed. Outgoing messages
	// go straight to Publisher.Put.
	RoleOwner Role = "owner"

	// RoleMember — this visor subscribes to the owner's feed.
	// Outgoing messages are POSTed to the owner via the existing
	// 1:1 skychat /message with a group_id tag.
	RoleMember Role = "member"
)

type RosterMutation

type RosterMutation struct {
	GroupID   uuid.UUID     `json:"group_id"`
	Op        RosterOp      `json:"op"`
	PeerPK    cipher.PubKey `json:"peer_pk"`
	ParentSeq uint64        `json:"parent_seq"`
	IssuedAt  time.Time     `json:"issued_at"`
	IssuerPK  cipher.PubKey `json:"issuer_pk"`
	Signature cipher.Sig    `json:"signature"`
}

RosterMutation is one signed instruction to add or remove a peer from the group's member set. Issued by an admin and applied independently on every subscribing visor per the reconciliation rule documented in the RFC.

The signature covers the canonical-bytes encoding of GroupID + Op + PeerPK + ParentSeq + IssuedAt + IssuerPK. Wire format is JSON (this struct's MarshalJSON tags).

func UnmarshalRoster

func UnmarshalRoster(data []byte) (RosterMutation, error)

UnmarshalRoster decodes a JSON wire envelope into a RosterMutation and verifies the signature. Convenience for the subscriber path where every received leaf is decode-and-verify in lockstep.

type RosterOp

type RosterOp uint8

RosterOp is the kind of roster mutation. Stored as a small integer for canonical-bytes compactness and JSON round-trips as the same integer (intentionally not a string so a future op type never overlaps an old binary's parse).

const (
	// RosterOpAdd records a peer being added to the member set.
	// Idempotent: applying twice is a no-op.
	RosterOpAdd RosterOp = 1
	// RosterOpRemove records a peer being removed from the member
	// set. Idempotent: applying to a peer already absent is a no-op.
	RosterOpRemove RosterOp = 2
)

type RotationReason

type RotationReason string

RotationReason names why a key came due, for logs and for the operator-facing status the UI shows. Empty means not due.

const (
	// RotationReasonAge means the current key has been current longer
	// than DefaultKeyMaxAge (plus this visor's jitter).
	RotationReasonAge RotationReason = "age"

	// RotationReasonVolume means the current key has sealed more than
	// DefaultKeyMaxMessages messages.
	RotationReasonVolume RotationReason = "volume"
)

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session is one live group as this visor knows it — either as the owner (publisher) or as a member (subscriber). Safe for concurrent use after Open returns; Send / Close may be called from any goroutine.

func Open

func Open(cfg Config) (*Session, error)

func (*Session) BroadcastModeration

func (s *Session) BroadcastModeration()

BroadcastModeration re-publishes the current moderation state as signed mod/ gossip on this session's own feed, so a late joiner converges to it the same way BroadcastRoster handles membership.

Only admins' mutations are authoritative, so non-admin sessions are a no-op. Idempotent on the receive side.

func (*Session) BroadcastRoster

func (s *Session) BroadcastRoster()

BroadcastRoster re-publishes the current member + admin set as signed roster/ admin gossip on this session's own feed. Joiners bootstrap their Record from the invite as just [owner, self] and converge by hydrating these leaves via the reconciler — so WITHOUT this, a member added at CREATE time (--member / Create's initialMembers) leaves no roster/ leaf and is never learned by a late joiner (the reconciler only applies leaves that exist on the feed).

Only an admin's mutations are authoritative (the reconciler rejects non-admin issuers), so only admins broadcast; a non-admin or publisher-less session is a no-op. Idempotent on the receive side (Add of an existing member is a no-op), so it's safe to call on every owner-session open. Best-effort: publish errors are debug-logged and retried on the next open.

func (*Session) CanPostLocally

func (s *Session) CanPostLocally() (bool, string)

CanPostLocally reports whether THIS visor may currently post, and why not. The send-path counterpart of senderAllowedToPost: checking locally means a muted operator gets an immediate, explanatory error instead of publishing into a feed where every reader silently drops it.

func (*Session) Close

func (s *Session) Close() error

Close tears down the subscriber + publisher (and the underlying CXO node, owned by the publisher).

Safe to call concurrently and repeatedly: the body runs exactly once and later callers get the same error. Without the Once, two closers race on the s.pub / s.sub / relay fields (read-then-write with no lock) — reachable whenever a ctx-done teardown goroutine and an explicit Close both fire, e.g. the native-TCP group path in commands.startCXOGroup.

func (*Session) Connect

func (s *Session) Connect(ctx context.Context) error

func (*Session) FileKeys

func (s *Session) FileKeys(fileID string) (seal []byte, open [][]byte, err error)

FileKeys is the live-session form, for the standalone chat app that owns its group session in-process instead of reaching a visor over RPC.

func (*Session) HasLegacySub

func (s *Session) HasLegacySub() bool

HasLegacySub reports whether this session carries an attached legacy s.sub owner-feed subscriber. True only for member-role sessions where openMember successfully created the legacy subscriber; false on owner sessions and on member sessions whose s.sub was torn down by Close.

Used by detectStaleAndReconnect to skip the legacy-sub stale check on sessions that have no legacy subscriber to begin with.

func (*Session) ID

func (s *Session) ID() string

ID returns the group's UUID.

func (*Session) IsBanned

func (s *Session) IsBanned(pk cipher.PubKey) bool

IsBanned reports whether pk is barred from this group.

func (*Session) IsSubscriberAlive

func (s *Session) IsSubscriberAlive() bool

IsSubscriberAlive reports whether the subscriber side of this session is currently live. Surfaced through the chat-app's /status endpoint as the per-group `subscriber_alive` field so operators can spot a member session whose Connect failed silently or whose subscriber has been torn down.

Semantics — computed, not stored:

  • Owner-role sessions: always true (no subscriber to track; the publisher side is owned by this visor and there's nothing to "lose connection" to).
  • Member-role sessions, after Close: false (the session has been explicitly torn down; the underlying CXO subscriber is gone).
  • Member-role sessions, no inbound ever: false (we never had evidence the subscriber attached).
  • Member-role sessions, any prior inbound: true iff the last inbound is within subscriberStaleThreshold. Stale beyond that and the Manager's detectStaleAndReconnect loop will kick a reconnect; the flag stays false until the next inbound (or a successful Connect()) refreshes lastInboundNs.

The unification was previously four-flagged (subAlive bool + lastHeartbeatNs + LastMessageAt + Status's health semantics). Each pair of those could disagree, and we patched the disagreements one by one (#2530, #2532, #2533, #2534). The fix here removes the disagreement class entirely: there is ONE timestamp, and aliveness is a pure function of it.

func (*Session) KeyEpoch

func (s *Session) KeyEpoch() uint64

KeyEpoch returns the epoch of the key this session currently encrypts with. Zero for a plaintext group or a group still on its create-time key.

func (*Session) KeyRotationDue

func (s *Session) KeyRotationDue(now time.Time) (RotationReason, bool)

KeyRotationDue reports whether this session's key has covered enough to be replaced, and why. Always false for a plaintext group (no key) and for a session whose visor is not an admin (it could mint a key, but every other member would reject the leaf).

func (*Session) LastInbound

func (s *Session) LastInbound() time.Time

LastInbound returns the time at which this session most recently observed any inbound CXO event — a chat leaf, an owner heartbeat, or any other update. Zero if no traffic has ever been seen AND Record.LastMessageAt was zero at Open (so the seed yielded zero).

Used by Manager.detectStaleAndReconnect to identify subscribers that have silently stopped pulling from the owner, and by IsSubscriberAlive to compute the liveness boolean operators see in /status.

func (*Session) LegacySubLastInbound

func (s *Session) LegacySubLastInbound() time.Time

LegacySubLastInbound returns the time the legacy s.sub owner-feed subscriber most recently observed an inbound CXO event (or a successful Connect). Zero time on owner sessions (s.sub is nil), freshly-opened member sessions that haven't yet had their first Connect succeed, and torn-down sessions.

Used by Manager.detectStaleAndReconnect to spot a wedged owner-feed subscriber independently of the per-peerSub liveness map (#2606). s.sub lives outside that map by design (deferred D4 cleanup), so it needs its own staleness signal.

func (*Session) MessagesSealed

func (s *Session) MessagesSealed() uint64

MessagesSealed returns how many messages this session has published under its current key — the volume half of the rotation policy, and the number to reach for when diagnosing why a group did or didn't re-key when expected.

func (*Session) Moderation

func (s *Session) Moderation() ModState

Moderation returns a snapshot of this session's live moderation state. Used by the manager to answer info queries without reaching into session internals.

func (*Session) MutationSeen

func (s *Session) MutationSeen() map[string]time.Time

MutationSeen returns a copy of the live watermark set.

func (*Session) PeerLastInbound

func (s *Session) PeerLastInbound(pk cipher.PubKey) time.Time

PeerLastInbound returns the per-peerSub last-inbound timestamp for the given peer PK. Zero time = no inbound ever observed from that peer (peerSub is either never-connected or silent since startup). Returns zero time if pk isn't a known peer of this session.

Used by Manager.detectStaleAndReconnect to detect a silent peer's failed peerSub even when other peers in the session are chatty enough to keep the session-level LastInbound() fresh. Pre-this- method the reconnect machinery only saw the session aggregate and silently-failing peerSubs stayed in that state indefinitely.

func (*Session) PeerPKs

func (s *Session) PeerPKs() []cipher.PubKey

PeerPKs returns the set of peer PKs this session has a peerSub for (i.e., every group member other than self that this visor follows). The slice is a snapshot — safe to iterate after the function returns without holding the session's internal lock. Sorted for determinism so callers iterating the slice produce stable behavior (mostly helpful in tests and structured-log output).

func (*Session) PeerUpdateCounts

func (s *Session) PeerUpdateCounts() map[cipher.PubKey]uint64

PeerUpdateCounts returns a snapshot of the per-peer OnUpdate-callback count map. Counts are monotonic across the session's lifetime; readers may compare deltas across two snapshots to measure inbound activity per peer over an interval.

Empty map for owner-role sessions (no peerSubs). The returned map is a copy — the caller can read it without holding peerSubsMu.

func (*Session) Port

func (s *Session) Port() uint16

Port returns the DMSG port this session uses.

func (*Session) PublishAdminMutation

func (s *Session) PublishAdminMutation(op AdminOp, peerPK cipher.PubKey, parentSeq uint64) (uint64, error)

PublishAdminMutation is the parallel emitter for AdminMutation — promote / demote of a peer's admin authority. Same envelope shape and semantics as PublishRosterMutation, scoped to the admin-feed path (AdminPathPrefix/<seq>) and using s.adminSeq.

func (*Session) PublishModMutation

func (s *Session) PublishModMutation(op ModOp, peerPK cipher.PubKey, parentSeq uint64) (uint64, error)

PublishModMutation writes a signed moderation mutation onto this session's own feed. Peer-scoped ops take a peerPK; group-scoped ops (read-only toggles) must pass the zero value.

Same shape as PublishRosterMutation / PublishAdminMutation.

func (*Session) PublishRosterMutation

func (s *Session) PublishRosterMutation(op RosterOp, peerPK cipher.PubKey, parentSeq uint64) (uint64, error)

PublishRosterMutation signs op+peerPK as a RosterMutation envelope (per docs/skychat_group_gossip_rfc.md) and writes it to this session's publisher under RosterPathPrefix/<seq>. Subscribers on other visors observe the leaf via their normal OnUpdate path; the reconciler (step 3 of the gossip impl) decodes, verifies, and applies.

Caller is responsible for ensuring this visor has roster authority to issue the mutation (Manager.AddMember already gates on r.IsAdmin(myPK) before calling this). The envelope's IssuerPK is always this session's MyPK; signature uses MySK.

parentSeq is the highest roster-mutation seq THIS issuer has observed before issuing — used by the reconciler to void mutations from a since-demoted admin. Callers that don't track parent_seq can pass 0; the reconciler treats parent_seq=0 as "no causal constraint" and applies the mutation if signature + admin-set gate pass.

Returns the seq the mutation was published at (>=1), or an error if signing or Put fails. The session must have a live publisher (s.pub != nil) — sessions opened in a degenerate state (no DMSG) have nil publishers and return an error here.

func (*Session) ReconnectLegacySub

func (s *Session) ReconnectLegacySub(ctx context.Context) error

ReconnectLegacySub re-runs Connect on the legacy s.sub owner-feed subscriber. Mirrors ReconnectPeer's shape but targets the s.sub field rather than a peerSubs entry. No-op + nil error on owner- role sessions and on sessions where s.sub is nil.

On success: bumps both subLastInboundNs and the session-wide lastInboundNs. Connect itself is positive evidence the legacy subscriber is attached even before the first inbound leaf arrives, and on member sessions s.sub being healthy is sufficient to declare the session as a whole healthy — same rationale as Session.Connect's bump-on-success path. Skipping the session-wide bump would leave subscriber_alive=false after a successful reconnect until the next heartbeat or chat message arrives, which is the symptom this fix is meant to remove.

On failure: returns Subscriber.Connect's error verbatim; neither timestamp is bumped so the next detectStaleAndReconnect tick still sees the session as stale and tries again.

Honors the provided context: ctx cancellation returns ctx.Err() promptly, with the same goroutine-leak protection used by Connect + ReconnectPeer (buffered done channel, treestore.Subscriber is not yet ctx-aware internally).

func (*Session) ReconnectPeer

func (s *Session) ReconnectPeer(ctx context.Context, pk cipher.PubKey) error

ReconnectPeer re-runs Connect on a single peerSub. Counterpart to Session.Connect which retries every peer in one shot. The detectStaleAndReconnect loop uses this for per-peer recovery so a stale silent peer doesn't trigger redundant Connect retries on already-healthy peers (and the per-peer success/failure attribution keeps the manager's backoff state cleaner).

On success: bumps the peer's per-peer lastInbound timestamp. Connect is positive evidence the peerSub is attached, so the liveness window for THIS peer restarts here.

On failure: returns the underlying Subscriber.Connect error verbatim; caller decides whether to log, retry, or apply backoff. peerLastInboundNs is NOT bumped, so the staleness signal stays honest.

No-op + nil error if pk isn't a known peer of this session.

Honors the provided context: if ctx fires before the underlying Subscriber.Connect returns, ReconnectPeer returns ctx.Err() promptly. The Subscriber.Connect goroutine continues to run in the background (since treestore.Subscriber isn't yet ctx-aware internally — see Session.Connect's comment for the longer-term fix) but the buffered done channel prevents goroutine leakage: the goroutine completes and exits cleanly when the network IO finishes.

func (*Session) ReplayHistoryThrough

func (s *Session) ReplayHistoryThrough(handler MessageHandler, cap int)

ReplayHistoryThrough pumps the last `cap` messages from every publisher/subscriber tree this session can reach through the given handler. Empty or uninitialized sessions are no-ops.

D1 sources walked:

  • the local publisher (s.pub) — owner's own sends + heartbeats + any owner-relay re-publishes; member's own direct sends
  • the legacy owner-feed subscriber (s.sub) — present on member sessions, mirrors the owner's feed
  • every D1 per-PK peer subscriber (s.peerSubs) — other members' direct sends published to their own feeds

Sorting is by Message.TS (decoded), not by path, because the D1 path layout msgs/<senderHex>/<ts-nano>/<seq> would sort by sender first if compared lexically — yielding per-sender chronological groups but not a global chronological order.

Best-effort: per-leaf decode / decrypt failures are silently skipped (consistent with onUpdate's policy — a torn leaf shouldn't block the rest of replay).

func (*Session) RotateKeyFor

func (s *Session) RotateKeyFor(recipients []cipher.PubKey) (uint64, error)

RotateKeyFor mints a new group key, seals it to each recipient, publishes the signed rotation, and installs the key locally. Returns the new epoch.

The recipient list is the CALLER's to supply, and that is deliberate. The session's cfg.Record.Members is a create-time snapshot that roster changes never write back to — the same trap isMember documents — so deriving recipients here would seal the key to whoever was in the group when the session opened. The Manager holds the authoritative roster (it just persisted it) and filters self and banned PKs out.

Ordering matters for the same reason: the caller must have removed the evicted PK from the roster BEFORE calling, or the eviction's whole purpose is defeated by handing the new key to the peer being cut off. Manager.moderate and Manager.RemoveMember both persist the shrunken roster first.

A member that is offline right now is still a recipient — its wrap sits in the leaf on our feed and it unwraps when it reconnects. No re-broadcast is needed: unlike create-time roster state, which leaves no leaf behind and therefore needs BroadcastRoster, a rotation IS a leaf and stays in the tree.

func (*Session) Send

func (s *Session) Send(text string) error

Send publishes a chat message. Owner-side only: members must SubmitToOwner instead. Returns an error if invoked on a member-role session.

For ModePrivate groups, the body is AES-256-GCM-sealed before being stored as the leaf value. The path itself (timestamp + seq) stays plaintext.

func (*Session) SetAdminRoster

func (s *Session) SetAdminRoster(admins []cipher.PubKey) ([]cipher.PubKey, error)

SetAdminRoster re-evaluates this session's peerSubs against a new admin set under the admin-aggregator topology (#2685). Idempotent add/remove of per-peer subscriptions to match the rule:

desired_set = { pk in Members : pk != MyPK AND (iAmAdmin OR pk in Admins) }

Hooks Manager.PromoteAdmin / Manager.DemoteAdmin: when a peer's admin status flips, the live session's input topology must follow or the peer's leaves stop reaching non-admin members. Mirrors SetAllowlist's structure (compute desired, drop removed, add new) and returns the evicted peers so the caller can clean (groupID, peerPK)-keyed state (per-peer reconnect backoff, etc.).

admins is the new admin set; cfg.Record.Members is read live so the member-set side of the desired computation reflects whatever the most recent SetAllowlist landed. Founder admin status is implicit — the IsAdmin check uses the union of the founder PK and Admins.

Owner-role sessions: this is a no-op because openOwner already subscribes to every non-self member (owners are always admins by IsAdmin contract, so the rule degenerates to "subscribe to all"). Member-role admin status can change at runtime; that's the case this function handles.

Errors: returns (evicted, error). Connect failures on newly-added peers are logged at Debug and the entry stays in peerSubs — the background reconnect loop picks them up on the next tick. Returns an error only if the session has no node to attach subs to (a torn-down session) or the receiver is nil; per-peer subscriber creation errors are logged + skipped, matching SetAllowlist's best-effort contract.

func (*Session) SetAllowlist

func (s *Session) SetAllowlist(members []cipher.PubKey) ([]cipher.PubKey, error)

SetAllowlist updates the publisher's subscriber allowlist AND the relay-gate's view of the member set, both live. Used when an invite is issued or a member is removed. Returns the list of peers whose peerSubs were torn down — the caller (Manager) is responsible for clearing any state keyed by (group ID, peer PK), e.g. the per-peer reconnect backoff map.

Two gates that both need refreshing:

  • publisher's CXO subscriber allowlist (s.pub.SetAllowlist) — gates who can SUBSCRIBE to the CXO feed.
  • relay-side isMember check (s.members) — gates who the relay listener accepts framed RelayMessage envelopes from.

Pre-fix, only the publisher side was refreshed; s.members was frozen at Open from cfg.Record.Members. A member added AFTER Open could subscribe to the feed (publisher accepted them), but their SubmitToOwner frames hit the relay listener and got rejected with "sender not in allowlist" because isMember walked the stale list. Visible symptom: member sends silently dropped on the owner; the member's local Record.Members showing the original allowlist while the owner had since added more peers. Roles: EVERY session has a publisher whose allowlist is the member set (openMember sets it too, so peers can subscribe to a member's own feed), so both roles refresh it here. This used to be owner-only, which froze a member's allowlist at Open — a peer added afterwards was refused by that member's publisher forever, so it could never be followed. Invisible while only admins were followed and admins existed from create time; fatal for peer backfill, and already a latent bug for an admin promoted later.

The peer-sub reconciliation below is still owner-shaped ("follow every member"), so it stays gated on role. Member sessions get their subscription set from SetAdminRoster, which applyRosterLeaf calls alongside this.

func (*Session) SetGroupKey

func (s *Session) SetGroupKey(st KeyState)

SetGroupKey pushes key state from the Manager onto a live session — used after the Manager rotates or learns a key out of band (the admission response). Idempotent.

func (*Session) SetJoinRequestHandler

func (s *Session) SetJoinRequestHandler(h JoinRequestHandler)

SetJoinRequestHandler installs the admission decision callback. See the onJoinRequest field comment: the session authenticates the requester, the handler decides.

func (*Session) SetKeyChangeHandler

func (s *Session) SetKeyChangeHandler(f func(KeyState))

SetKeyChangeHandler installs the callback fired with the converged key state after a rotation is applied (or issued), so the Manager can persist it. Parallel to SetModChangeHandler.

Persistence here is not decoration: a visor that applied a rotation and then restarted without saving it would come back holding only the old key — unable to read anything published since, and encrypting into a key the rest of the group has retired.

func (*Session) SetMessageHandler

func (s *Session) SetMessageHandler(h MessageHandler)

SetMessageHandler registers (or replaces) the inbound-message callback. Pass nil to unregister.

func (*Session) SetModChangeHandler

func (s *Session) SetModChangeHandler(f func(ModState))

SetModChangeHandler installs a callback invoked with a snapshot of the converged moderation state whenever the reconciler mutates it. The manager uses it to persist the updated Record. nil is fine — in-memory state still converges, only durability is skipped.

func (*Session) SetModeration

func (s *Session) SetModeration(st ModState) []cipher.PubKey

SetModeration replaces the live moderation state (the local-command path, parallel to SetAllowlist for the roster). Returns the PKs newly banned by this call so the caller can tear down their subscriptions.

Owner-side the caller follows up with SetAllowlist over the post-ban member list: a banned PK must lose its CXO seat, which is what makes a ban structural rather than merely cooperative.

func (*Session) SetMutationSeenHandler

func (s *Session) SetMutationSeenHandler(f func(map[string]time.Time))

SetMutationSeenHandler installs the callback that persists advanced watermarks. nil is fine — the guard still works for the life of the process, but a restart would forget the watermarks and re-open the replay window, so the Manager always installs one.

func (*Session) SetRosterChangeHandler

func (s *Session) SetRosterChangeHandler(f func(members, admins []cipher.PubKey))

SetRosterChangeHandler installs a callback invoked (with a snapshot of the reconciled members + admins) whenever the reconciler mutates this session's roster. The manager uses it to persist the updated Record to its store so group info/list reflect the converged roster and it survives restart. nil is fine — the in-memory session state + peer-subscriptions still converge; only on-disk persistence is skipped.

func (*Session) SubmitToOwner

func (s *Session) SubmitToOwner(ctx context.Context, text string) error

SubmitToOwner is the member-side outbound path: dial the owner's relay listener, write a framed RelayMessage, close. The owner's acceptRelay handler validates + republishes. Returns an error if invoked on an owner-role session (owners use Send directly) or if neither transport can reach the owner.

Transport selection: skynet first (so groups work over arbitrary skywire transports including stcpr/sudph), dmsg fallback if the networker isn't registered yet or the skynet dial fails. The dmsg path is the bootstrap-time fallback and the failure recovery for peers whose router can't currently reach the owner. Matches the general principle that every visor port served on dmsg is also served on skynet — and clients prefer skynet but tolerate dmsg.

func (*Session) Unsend

func (s *Session) Unsend(tsNano int64) error

Unsend deletes a message this session previously published, identified by its timestamp (UnixNano — the value carried in the delivered Message.TS, so the UI already has it). Only leaves on THIS session's own feed can be removed — a publisher controls only its own feed — so unsend is inherently sender-scoped and needs no extra authority check.

PrunePrefix wipes the msgs/<myPK>/<ts> subtree, i.e. the single leaf at that timestamp (seq lives one level below ts). CXO's snapshot-diff replication then reports the removal to every subscriber (see treestore TestSubscriberApplySnapshotReportsDeletes), so the message is genuinely deleted from members' replicas — not merely hidden locally. The unavoidable caveat is inherent to any federated store: a member that is offline forever, or a modified client that archived the bytes, cannot be forced to forget.

type Status

type Status string

Status enumerates a Record's lifecycle, mirroring pairing.Status where it makes sense.

const (
	// StatusPending — we've registered the group locally but the
	// CXO publisher / subscriber haven't completed their handshake
	// yet (owner just created it, or member just joined and is
	// dialing). Treated as "almost active" by the UI.
	StatusPending Status = "pending"

	// StatusActive — owner's publisher is up, member's subscriber
	// is connected. Messages flow.
	StatusActive Status = "active"

	// StatusLeft — member side: the operator left this group. The
	// record stays for audit / re-join via the same invite. The
	// publisher / subscriber are torn down on transition.
	StatusLeft Status = "left"

	// StatusRevoked — owner side: the owner deleted the group. The
	// publisher is torn down; subscribers will see a stream end.
	// Kept in the local store so an operator can grep "what groups
	// did I run last quarter".
	StatusRevoked Status = "revoked"

	// StatusAwaitingApproval — requester side: we sent a join request
	// to a KindPrivate group and an admin hasn't ruled on it yet. No
	// session is open (we have no allowlist seat, so a subscribe would
	// be refused); the manager's pending-join loop re-asks on a timer
	// until the answer flips this to active or denied.
	StatusAwaitingApproval Status = "awaiting_approval"

	// StatusDenied — requester side: an admin denied the request. A
	// terminal state the operator can retry from explicitly; we do
	// NOT keep re-asking, because a denial that auto-retried every
	// 30s would be indistinguishable from harassment on the admin's
	// pending list.
	StatusDenied Status = "denied"

	// StatusBanned — requester side: the group told us this PK is
	// banned. Terminal and not retryable; kept (rather than deleted)
	// so the UI can explain why the group vanished instead of
	// silently dropping it.
	StatusBanned Status = "banned"
)

func (Status) IsTerminal

func (s Status) IsTerminal() bool

IsTerminal reports whether a status means "this group is over for us" — no session should be opened and no reconnect attempted. Used by Resume and by the browser-facing list filter.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is a bolt-backed group record store. Safe for concurrent use.

func OpenStore

func OpenStore(path string, sk cipher.SecKey) (*Store, error)

OpenStore opens (or creates) the bolt file at path. The parent dir is created if missing.

sk is the visor's secret key, used to derive the at-rest key that seals each group's AES key. It is required: a store opened without it would write group keys in the clear, which is the state store_seal.go exists to end. Records written by a different visor's key will not open.

func (*Store) Close

func (s *Store) Close() error

Close releases the bolt file handle.

func (*Store) Delete

func (s *Store) Delete(id string) error

Delete removes the record for id. No-op if absent.

func (*Store) DeleteJoinRequests

func (s *Store) DeleteJoinRequests(groupID string) error

DeleteJoinRequests drops every request against groupID. Called when the group is deleted or left so the queue doesn't outlive its group.

func (*Store) Get

func (s *Store) Get(id string) (Record, bool, error)

Get returns the record for id and a boolean indicating presence.

func (*Store) GetJoinRequest

func (s *Store) GetJoinRequest(groupID string, pk cipher.PubKey) (JoinRequest, bool, error)

GetJoinRequest returns the request for (groupID, pk) if one exists.

func (*Store) List

func (s *Store) List() ([]Record, error)

List returns every persisted record. Order is bolt's bucket-sorted order (lexicographic on the UUID string).

func (*Store) ListJoinRequests

func (s *Store) ListJoinRequests(groupID string) ([]JoinRequest, error)

ListJoinRequests returns every request recorded against groupID, newest first so an admin's queue reads top-down.

func (*Store) MarkMessage

func (s *Store) MarkMessage(id string, ts time.Time) error

MarkMessage updates LastMessageAt to the given timestamp.

func (*Store) MigratedRecords

func (s *Store) MigratedRecords() int

MigratedRecords reports how many records OpenStore had to re-seal because they still held plaintext key material. Non-zero exactly once per upgrade; the visor logs it so an operator can see the migration happened rather than having to infer it.

func (*Store) Put

func (s *Store) Put(r Record) error

Put writes (or replaces) the record for r.ID.

Replay watermarks are the one field that is merged rather than replaced. Every manager op is read-modify-Put, and the reconciler advances watermarks continuously in the background, so a Put built on a record read a moment earlier would carry stale watermarks and undo that progress — reopening the replay window for whatever target the reconciler had just pinned. A lost watermark never re-converges (see the retention note in replay_guard.go), and the resulting failure is the silent one the guard exists to prevent: a replayed RosterOpAdd re-admitting someone an admin evicted. No caller ever legitimately lowers a watermark, so merging costs nothing.

func (*Store) PutJoinRequest

func (s *Store) PutJoinRequest(req JoinRequest) error

PutJoinRequest writes (or replaces) a join request.

func (*Store) SetKeyState

func (s *Store) SetKeyState(id string, st KeyState) error

SetKeyState persists a rotated group key plus the ring of retired keys. Read-modify-write inside the transaction, like every other setter here, so it cannot clobber a concurrent roster or moderation change.

func (*Store) SetMembers

func (s *Store) SetMembers(id string, members []cipher.PubKey) error

SetMembers replaces the Members slice. Owner-side: drives Publisher.SetAllowlist on the next push. Member-side: updates the "who's in this room" display.

func (*Store) SetModeration

func (s *Store) SetModeration(id string, st ModState) error

SetModeration persists a converged moderation snapshot.

func (*Store) SetMutationSeen

func (s *Store) SetMutationSeen(id string, seen map[string]time.Time) error

SetMutationSeen persists the replay guard's watermarks. Merges rather than replaces: a snapshot taken by one reconciler path can be written while another has already advanced a different target, and the guard must never move a watermark backwards.

func (*Store) SetStatus

func (s *Store) SetStatus(id string, status Status) error

SetStatus updates only the Status field of an existing record. Returns an error if no record exists for id.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL