diff --git a/CLAUDE.md b/CLAUDE.md index fe0d086..2ffa797 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,7 +179,7 @@ This is the most intricate part of the codebase and where most current work happ **Private groups (`Chat.IsPrivate`, in progress).** `StartChat` creates one from its `private_group` variant: no rules, title moderated and picture attached like any group's (both plaintext on the record), same idempotency, and open to every registered user. `IsPrivate` is immutable, written with the group (DynamoDB `is_private`, absent when false) and carried on `ChatRules` beside the rules and creator, so `chat/cache` holds it forever and it must never be set on a group that exists. What follows from it, **each decided on the flag and not on the absence of rules**: `Metadata.is_private`; a non-member has no standing under any mode (`Access.standing` and `PublicListenerStanding` refuse on `IsPrivate` before the rules are looked at: no listen, no preview, no chat preview stream) while `GetChat` still returns the record alone to any registered user; a private group has no `RuleSet` (`ChatRules.RuleSet` returns false on `IsPrivate`, so it can never be evaluated as an empty rule set open to all; the evaluator's chat-ID reads return `ErrNotGovernedByRules` for one), and `JoinChat` skips the rules for the rejoining creator; the unauthenticated view (`getPublicChat`, REDACTED only) is the record alone, as a registered non-member sees it, with no `in_lobby`; `JoinChat` is `DENIED` for everyone but the creator, who leaves and rejoins freely, and holders of an ACTIVE invite (see "Invites" below); a member's `JoinChat` is the OK no-op, answered off a roster summary read with no membership write, so a join racing the member's own leave cannot let them back in without an invite. **Key envelopes (`chat/key.go`)**: `SetKeyEnvelope` / `GetKeyEnvelope` are a member's alone, of a private group alone (a DM `DENIED` before any read, then the record for `NOT_FOUND`, then `IsPrivate`, then membership). Set stores the request's envelope as wrapped by the caller; **the first envelope a user wraps for themself stands** (a different one later is `ALREADY_SET`, the same one `OK`), while an envelope someone else wrapped for them is replaced by their own. Get returns the envelope (`key_envelope`, or `invite_key_bundle` when what is stored is an invite's copy: the invite ID, the signed copy and the joiner's secret envelope; exactly one is set) with `wrapped_by` and `wrapped_by_public_key` (the wrapper's account key, `Internal` when they have none), or `NO_ENVELOPE`. Nothing is published. **A private group has a key exactly when its creator has an envelope stored**: there is no flag on the record, since the creator's envelope is the only possible first one, is always self-wrapped, and is never deleted. `LeaveChat` discards the leaver's envelope atomically with the departure, except the creator's: it reads the cached rules first (`NOT_FOUND` off that read, a failed read fails the RPC) and passes `discardKeyEnvelope` to `RemoveGroupMember` for a non-creator leaving a private group. `SetKeyEnvelope` is not in that transaction, so one racing a leave can still write an envelope for a user who has just left; it is unreadable to them and holds the right key if they are readmitted (accepted). The server never opens an envelope and validates only its shape (proto validation). **SpeakerStanding (`Access.SpeakerStanding`)**: a member of a private group speaks exactly when the group has its key, decided on membership and the creator's envelope with the evaluator never consulted (`privateGroupSpeaker`; a private group has no rule set). The key check is the cached rules read plus one strongly consistent point read of the creator's envelope (`hasKey`, unexported); a positive is held in the `Access` for the life of the process (the envelope is never deleted), a negative never, so a keyless group's refused sends each cost that read and its first send after the key lands is admitted at once. A keyless group's members are `DENIED` every send, edit, deletion, typing notification and mention suggestion; a keyed group's members get a `SpeakerStanding` with `EncryptionRequired` under `CHAT_KEY_XCHACHA20POLY1305`, and a member holding no envelope of their own speaks too (the key is the group's). Messaging applies that standing to the content (`encryptionVerdict`, see "Encrypted content"): the chat-key scheme and nothing else. Encrypted blob uploads follow the same gate (see `blob/`). The group push body for encrypted content is " sent a message". **Lobby (`chat/lobby.go`)**: how anyone but the creator and an invite's holders gets in. Every lobby RPC gates on a group ID (DM `DENIED` before any read), the record (`NOT_FOUND`), then `IsPrivate` (`DENIED`); the creator's RPCs (`GetLobbyMembers`, `AdmitLobbyMember`, `DenyLobbyMember`) then require the recorded creator **while a member** (a departed creator is `DENIED` until they rejoin). A lobby exists only once the group has its key (`Access.hasKey`): `EnterLobby` and `AdmitLobbyMember` are `DENIED` for a keyless group; `LeaveLobby`/`DenyLobbyMember` are the no-op they are. `EnterLobby`: the creator is `DENIED` (they rejoin with `JoinChat`), a member `ALREADY_MEMBER` (answered off a membership read first, and again by the store's atomic check, which is the guarantee), then the store's `LOBBY_FULL`/`TOO_MANY_LOBBIES`; OK returns `Lobby{chat, entered_at}` with the chat hydrated as a non-member sees it plus `in_lobby`; a repeat is OK with the first entry and publishes nothing. `GetLobbyMembers` pages earliest first (`maxGetLobbyMembersPageSize` 100, `limit+1` for an exact `has_more`, a token `[version][chat ID][entered_at nanos][user ID]` bound to the chat and refused elsewhere), each `LobbyMember` hydrated with the public profile (`ProfileReader.GetLimitedPublicProfilesForRow`) and the account store's public key (`GetPubKeys`, one per user; a missing profile or key is `Internal`). `AdmitLobbyMember`: the target already a member is OK with their envelope untouched (checked before the write, which would replace it), else `Store.AdmitFromLobby` with the envelope wrapped by the creator (`NOT_IN_LOBBY` otherwise), announced exactly like `JoinChat` (`RosterUpdate.MembershipChanged` on the chat topic excluding the joiner, and `MemberJoined` with the hydrated metadata on the joiner's user topic, via `announcedMember`) plus a `LobbyUpdate.MemberLeft` to the creator. `DenyLobbyMember` removes the entry, tells the denied user nothing, and lets them re-enter. **`LobbyUpdate`s go to the recorded creator's user topic only** (`publishLobbyUpdate`; never the chat topic, so `redact.ChatUpdate` never sees one), whether or not the creator is currently a member (decided), best effort: `MemberEntered` carrying the `LobbyMember`, `MemberLeft` for a withdrawal, denial or admission. **Lobby pushes** (`sendLobbyPush`, through the `push.Pusher` that is a required `NewServer` argument, nil for none): a real `EnterLobby` pushes the recorded creator (" requested to join", "Someone" without a display name; `push.SendLobbyEnteredPush`) and a real admission pushes the admitted user ("Your request to join was approved"; `push.SendLobbyAdmittedPush`); a withdrawal or denial pushes no one. Both are titled by the group, `CHAT` category grouped and navigating like its chat pushes, with `ChatMetadata` carrying the type and no message or sender, no badge bump, never held back by a mute (not a message's push), and sent on a detached goroutine (10s bound), best effort. **`Metadata.in_lobby`** is set by `hydrate` for a non-member viewer of a private group off one strongly consistent `GetLobbyEntries` read across the set (never for a member, a public group or the unauthenticated view). Caps are the store's to enforce; the server names them (`lobbyLimits`, `WithLobbyLimits` on `NewServer`'s new variadic `ServerOption`s, for tests). **Join messages**: every real join of a private group writes a `SystemContent.UserJoined` system message into its log (`sendUserJoined` in `chat/member.go`, through `chat.SystemMessenger`, a required `NewServer` argument, nil for none, satisfied by `messaging.Sender.SendUserJoined` in `messaging/system.go`): an admission as `Admitted{admitted_by: creator}`, a join with an invite as `Invited{invited_by: the invite's creator}`, the creator's rejoin as `Direct` (`chat.JoinMethod`: `JoinedDirectly` / `AdmittedBy` / `InvitedBy`; chat names the way and the user, messaging builds the proto); never a creation, a departure, a no-op or a public group's join (public groups may be huge and open). It is sent after the membership write and before `hydrate`, detached with a 5s bound, **best effort** (a failure is logged, never fails the RPC, and since a retry is the no-op path, a lost message stays lost). Its client message ID is derived from (chat, user, roster version after the join), so a repeat writes nothing and each rejoin writes its own. As a system message it has no sender, so no read pointer, no activity record and **no push**; it does not count toward unread, but it is the group's last message (moves `last_activity`) and is broadcast on the chat topic (the joiner, whose stream follows the chat only on their `MemberJoined`, usually gets it by delta). The `MemberJoined` metadata and the RPC's response are hydrated from the record read before the write, so their `last_message` is still the previous one (accepted for now). **System messages name no one in `fallback_text`** (here "A new member joined the group"): people go in the structured variant, which clients render with current profiles, so a rename never leaves a stale name in the log. -**Invites (`chat/invite.go`, private groups).** Links that let a user join a private group without its creator's approval. **The link carries one secret the server never learns**: clients derive the `invite_token` (sent on `CreateInvite`, `GetInvite` and `JoinChat`; **never stored or logged**, since anyone holding it can join and use up the limit; logs carry `invite_id` hex alone, `inviteLog`), the `wrap_key` (binds the chat ID, never sent) and the `invite_id` (`MustDeriveInviteID`: `SHA-256("flipcash-group-invite-id-v1" ‖ token)[:16]`, computed by the server from every token it is sent, so nothing it stores or returns lets anyone join). The invite holds a copy of the chat key under `wrap_key` (`InviteKeyEnvelope`) signed by its creator's account key over `"flipcash-group-invite-sig-v1" ‖ chat ‖ invite ID ‖ nonce ‖ ciphertext` (`VerifyInviteSignature`, checked at creation, `INVALID_SIGNATURE`), so the server cannot forge a link with a key of its own; and the creator's `InviteSecretEnvelope` (the link secret self-wrapped, to rebuild the link). Link plus server records equal the chat key, so the invite's own copy (`#key`) is deleted once the invite is not ACTIVE (revoke and last use in their write, expiry by TTL); **each joiner's copy is not**: it is their only key until their client replaces it with `SetKeyEnvelope`, which deletes it and its secret envelope in that write, so a joiner who never re-wraps leaves a copy that the link opens for as long as they stay (a leave or ban discards it). Nothing records who joined with which invite, so a revoke cannot reach those copies; clients must re-wrap promptly after joining. **Who may invite**: `CanInvite`, a private group's recorded creator while a member (others could plant wrong keys); an invite the creator made keeps admitting while they are away. **Creating an invite is staff-only for now** (`canCreateInvite`, a transitional gate removed by deleting its check, decided 2026-10-26): `CreateInvite` answers `DENIED` to anyone else after its retry lookup, so a retry is answered whatever the caller's staff flag says now; `can_invite` does not reflect it, and listing, revoking and joining are unaffected. **Status is derived, never stored**: REVOKED > EXPIRED (clock, `expires_at`) > EXHAUSTED (`use_count >= max_uses`, 0 = unlimited) > ACTIVE (`Invite.StatusAt`). **`max_uses` counts joins** (decided 2026-10-26): every real join uses one, rejoins included; a retry uses none (the caller is a member by then, the no-op); no per-user use records, so who joined with which invite is not recorded. The invite admits only while ACTIVE, returning users included. **RPCs** (`CreateInvite` / `GetInvite` / `GetChatInvites` / `RevokeInvite`, nothing published): `CreateInvite` looks the derived ID up first (the caller's invite to this chat is the retry, OK whatever its status; anyone else's or another chat's `DENIED`), then the shared gate `inviterChat` (DM `DENIED` before any read, the record for `NOT_FOUND`, `CanInvite` off the record, then membership strongly consistent), the group must have its key (`DENIED`), `expires_at` after now and at most `MaxInviteDuration` (1 year) away (`INVALID_EXPIRES_AT`), the signature, then the store, capping ACTIVE invites per chat (`MaxActiveInvites` 20, `WithMaxActiveInvites` for tests; `TOO_MANY_INVITES`). `GetInvite` is any registered user's preview: the invite (`NOT_FOUND`), the chat (`NOT_FOUND`), `BANNED` for a banned caller, else the chat as `GetChat` shows it under FULL (`chatAsSeenBy`) and the invite's status, nothing else. `GetChatInvites` pages a chat's invites, every status, most recently created first (cap 100, exact `has_more`, token `[version][chat ID][created nanos][invite ID]` bound to the chat), off an index that may trail writes by a moment. `RevokeInvite` revokes and deletes the copy (`NOT_FOUND` for another chat's; a repeat is OK). **Joining** is `JoinChat` with `invite = {invite_token, secret_envelope}` (the joiner's self-wrapped link secret, so any of their devices can open the copy): a member is the OK no-op whatever the invite (even revoked or another chat's); the creator's rejoin ignores it; a public group with one is `INVITE_NOT_FOUND`, its rules not evaluated (only rules or an invite are checked, never both); otherwise `Store.JoinWithInvite` (`INVITE_NOT_FOUND` for missing or another chat's, then `INVITE_REVOKED` / `INVITE_EXPIRED` / `INVITE_EXHAUSTED`, then `BANNED`, judged in the write, so a banned user holding a dead invite is told why the invite does not admit them; the store also refuses the invite's own creator, `ErrOwnInvite`, after the member no-op, since the copy would replace their envelope, the group's key, with one `SetKeyEnvelope` could never replace; JoinChat never reaches it, routing the creator to a direct rejoin). A real join is announced like an admission (`announceJoin`: the `Invited` UserJoined message, `MemberJoined` to the joiner, `MembershipChanged` to the chat) plus a `LobbyUpdate.MemberLeft` to the creator when it took the joiner out of the lobby; **nobody is pushed**. The joiner then reads `invite_key_bundle` from `GetKeyEnvelope`, checks `wrapped_by` is the group's creator, verifies the signature against `wrapped_by_public_key`, opens the copy with the link secret, and replaces it with `SetKeyEnvelope`. Not built: member (non-creator) invites, which would need `SetKeyEnvelope`'s self-wrapped rule (`WrappedBy == caller`) to exempt invite copies, since a member joining by their own invite would otherwise hold a copy they could never replace; a chat-level creator key commitment; a creator view of who joined via which invite. +**Invites (`chat/invite.go`, private groups).** Links that let a user join a private group without its creator's approval. **The link carries one secret the server never learns**: clients derive the `invite_token` (sent on `CreateInvite`, `GetInvite` and `JoinChat`; **never stored or logged**, since anyone holding it can join and use up the limit; logs carry `invite_id` hex alone, `inviteLog`), the `wrap_key` (binds the chat ID, never sent) and the `invite_id` (`MustDeriveInviteID`: `SHA-256("flipcash-group-invite-id-v1" ‖ token)[:16]`, computed by the server from every token it is sent, so nothing it stores or returns lets anyone join). The invite holds a copy of the chat key under `wrap_key` (`InviteKeyEnvelope`) signed by its creator's account key over `"flipcash-group-invite-sig-v1" ‖ chat ‖ invite ID ‖ nonce ‖ ciphertext` (`VerifyInviteSignature`, checked at creation, `INVALID_SIGNATURE`), so the server cannot forge a link with a key of its own; and the creator's `InviteSecretEnvelope` (the link secret self-wrapped, to rebuild the link). Link plus server records equal the chat key, so the invite's own copy (`#key`) is deleted once the invite is not ACTIVE (revoke and last use in their write, expiry by TTL); **each joiner's copy is not**: it is their only key until their client replaces it with `SetKeyEnvelope`, which deletes it and its secret envelope in that write, so a joiner who never re-wraps leaves a copy that the link opens for as long as they stay (a leave or ban discards it). Nothing records who joined with which invite, so a revoke cannot reach those copies; clients must re-wrap promptly after joining. **Who may invite**: `CanInvite`, a private group's recorded creator while a member (others could plant wrong keys); an invite the creator made keeps admitting while they are away. **Creating an invite is staff-only for now** (`canCreateInvite`, a transitional gate removed by deleting its check, decided 2026-10-26): `CreateInvite` answers `DENIED` to anyone else after its retry lookup, so a retry is answered whatever the caller's staff flag says now; `can_invite` does not reflect it, and listing, revoking and joining are unaffected. **Status is derived, never stored**: REVOKED > EXPIRED (clock, `expires_at`) > EXHAUSTED (`use_count >= max_uses`, 0 = unlimited) > ACTIVE (`Invite.StatusAt`). **`max_uses` counts joins** (decided 2026-10-26): every real join uses one, rejoins included; a retry uses none (the caller is a member by then, the no-op); no per-user use records, so who joined with which invite is not recorded. The invite admits only while ACTIVE, returning users included. **RPCs** (`CreateInvite` / `GetInvite` / `GetChatInvites` / `RevokeInvite`, nothing published): `CreateInvite` looks the derived ID up first (the caller's invite to this chat is the retry, OK whatever its status; anyone else's or another chat's `DENIED`), then the shared gate `inviterChat` (DM `DENIED` before any read, the record for `NOT_FOUND`, `CanInvite` off the record, then membership strongly consistent), the group must have its key (`DENIED`), `expires_at` after now and at most `MaxInviteDuration` (1 year) away (`INVALID_EXPIRES_AT`), the signature, then the store, capping ACTIVE invites per chat (`MaxActiveInvites` 20, `WithMaxActiveInvites` for tests; `TOO_MANY_INVITES`). `GetInvite` is the preview for anyone holding the link, **auth optional** (the token is the credential; auth that is set must be valid): the invite (`NOT_FOUND`), the chat (`NOT_FOUND`), then for an authenticated caller `BANNED` if banned, else the chat as `GetChat` shows it under FULL (`chatAsSeenBy`); a caller with no auth is never `BANNED` and gets the chat's public view (`publicChatMetadata`, shared with `getPublicChat`: a private group's record alone), which is how a web page a link opens previews it; with either goes the invite's status, nothing else. `GetChatInvites` pages a chat's invites, every status, most recently created first (cap 100, exact `has_more`, token `[version][chat ID][created nanos][invite ID]` bound to the chat), off an index that may trail writes by a moment. `RevokeInvite` revokes and deletes the copy (`NOT_FOUND` for another chat's; a repeat is OK). **Joining** is `JoinChat` with `invite = {invite_token, secret_envelope}` (the joiner's self-wrapped link secret, so any of their devices can open the copy): a member is the OK no-op whatever the invite (even revoked or another chat's); the creator's rejoin ignores it; a public group with one is `INVITE_NOT_FOUND`, its rules not evaluated (only rules or an invite are checked, never both); otherwise `Store.JoinWithInvite` (`INVITE_NOT_FOUND` for missing or another chat's, then `INVITE_REVOKED` / `INVITE_EXPIRED` / `INVITE_EXHAUSTED`, then `BANNED`, judged in the write, so a banned user holding a dead invite is told why the invite does not admit them; the store also refuses the invite's own creator, `ErrOwnInvite`, after the member no-op, since the copy would replace their envelope, the group's key, with one `SetKeyEnvelope` could never replace; JoinChat never reaches it, routing the creator to a direct rejoin). A real join is announced like an admission (`announceJoin`: the `Invited` UserJoined message, `MemberJoined` to the joiner, `MembershipChanged` to the chat) plus a `LobbyUpdate.MemberLeft` to the creator when it took the joiner out of the lobby; **nobody is pushed**. The joiner then reads `invite_key_bundle` from `GetKeyEnvelope`, checks `wrapped_by` is the group's creator, verifies the signature against `wrapped_by_public_key`, opens the copy with the link secret, and replaces it with `SetKeyEnvelope`. Not built: member (non-creator) invites, which would need `SetKeyEnvelope`'s self-wrapped rule (`WrappedBy == caller`) to exempt invite copies, since a member joining by their own invite would otherwise hold a copy they could never replace; a chat-level creator key commitment; a creator view of who joined via which invite. **Group profile fields.** A group's record carries a `Description` (`chat/description.go`: `ValidateDescription`, the bio rules — empty is none, ≤ `MaxDescriptionLength` 160 characters, something visible, no control characters but a line break, stored as written; a violation is `InvalidArgument` before anything is read) and two pictures, `ProfilePictureBlobID` (the avatar; DynamoDB attribute still `picture`) and `CoverPictureBlobID` (the banner, `cover_picture`), each the ORIGINAL's blob ID, attached with `blob.Integration.SetAsChatMedia`. A description is moderated by `ClassifyText` alone (no group-title classifier; `ErrUnsupportedLanguage` is fatal for now, as for a bio), only when non-empty. **Feeds leave the cover out**: `hydrate` takes a `metadataDetail`, and `GetDmChatFeed` / `GetGroupChatFeed` pass `listDetail`, which drops `cover_picture` and never resolves its renditions (the proto lets a list view omit it and tells clients to fetch it with `GetChat`), but **keeps `description`**, since a list row may show it (decided 2026-10-06); every other carrier (`GetChat` incl. the public view, `StartChat`, `EditChat`, `JoinChat`, the lobby, `RosterUpdate.MemberJoined` metadata) passes `fullDetail`. @@ -187,7 +187,7 @@ This is the most intricate part of the codebase and where most current work happ **Permissions (`ViewerState.permissions`).** Server-computed per read from the record and membership (`Chat.PermissionsFor`), never stored, and carried on **every** `ViewerState` carrier (`ToProto(permissions)`: hydrate, mute/unmute responses, `ViewerStateChanged`; the clear-on-leave publishes with none). `hydrate` **always sets `viewer_state` for a member** (the zero record with permissions when they have written none) and **never for a non-member**, whatever record persists for them (the read is skipped for a non-member's standing). The version does **not** move when permissions change with membership (decided: a creator's leave/rejoin is announced by `RosterUpdate`s carrying fresh metadata, not by a version bump), so the same version can carry different `can_edit` before and after the viewer's own transition. `moderation` (`ModerationPermissions{CanSilence, CanBan}`, projected only when it grants something, so unset for every DM) has each flag decided exactly as `can_edit`, and so does `can_invite` (`Permissions.CanInvite`), the recorded creator while a member, of a private group alone, the recorded creator while a member; the two gate their own RPCs, so they can be granted apart later. -**Roster read (`chat/roster.go`).** `GetRoster` pages a chat's members most-recently-joined first (`RosterPosition`: `(joined_at desc, user_id desc)`, total so a cursor resumes strictly after a member), every page carrying the `RosterSummary`. Two shapes chosen by size, like the mute fan-out: at or below `rosterWholeReadCap` (1000) the roster is read **whole and strongly consistent** (`GetGroupRoster`, one `Query` of the base partition that returns the `#meta` item at its head; a Query is consistent per item, not as a set, so the read is verified — joined rows == `member_count` and no row version above the summary's proves no transition straddled it — and re-read up to 3× otherwise, so the page is exactly the roster at that version), sorted and sliced per page; above it, `GetGroupRosterPage` ranges the sparse `by_joined_at` GSI descending (eventually consistent, billed by the page, `limit+1` for an exact `has_more`) beside a separate summary read. The proto is written for the weaker shape and the client never learns which it got. The token (`[version][chat ID padded to 16][joined_at nanos][user ID]`) is bound to the chat and refused elsewhere. Gate is **membership alone** (`Access.IsMemberWithChat`; decided 2026-10-06): a non-member is `DENIED` even when a group's listener rules let them read its messages, since who is in a chat is shown only to its members. Public groups have an operator kill switch, the `disablePublicGroupRoster` constructor flag: when set, a call naming a public group is `DENIED` after the record read and before the membership read, members included (a result, not a transient error: a group member holds no roster to fall back on, so a retry would only be refused again); DMs and private groups are always served to their members, whatever the flag says. Pointers are hydrated for a DM's participants only, never for group members. **`Member.joined_at` / `Member.version`** are the membership row's join time and version stamp, set for group members on roster pages and on the joiner's own `RosterUpdate.MemberJoined` (`announcedMember`: one strongly consistent `GetGroupMemberRecords` point read after a real join/creation, so the announcement carries the record the write produced), **unset on `Metadata.members`** (the viewer's own entry needs neither, and `hydrate` reads no membership rows) and never for a DM's participants. A departure's version is the `roster_summary.version` on its `MemberLeft`, so a client merges pages against the stream per member by version. +**Roster read (`chat/roster.go`).** `GetRoster` pages a chat's members most-recently-joined first (`RosterPosition`: `(joined_at desc, user_id desc)`, total so a cursor resumes strictly after a member), every page carrying the `RosterSummary`. Two shapes chosen by size, like the mute fan-out: at or below `rosterWholeReadCap` (1000) the roster is read **whole and strongly consistent** (`GetGroupRoster`, one `Query` of the base partition that returns the `#meta` item at its head; a Query is consistent per item, not as a set, so the read is verified — joined rows == `member_count` and no row version above the summary's proves no transition straddled it — and re-read up to 3× otherwise, so the page is exactly the roster at that version), sorted and sliced per page; above it, `GetGroupRosterPage` ranges the sparse `by_joined_at` GSI descending (eventually consistent, billed by the page, `limit+1` for an exact `has_more`) beside a separate summary read. The proto is written for the weaker shape and the client never learns which it got. The token (`[version][chat ID padded to 16][joined_at nanos][user ID]`) is bound to the chat and refused elsewhere. Gate is **membership, or an invite link** (`Access.IsMemberWithChat`; decided 2026-10-06, the link 2026-10-26): a non-member is `DENIED` even when a group's listener rules let them read its messages, since who is in a chat is shown only to its members and to holders of a link that would let them join. A request carrying `invite_token` for an invite to the chat that admits users now (`Invite.AdmitsAt`, `inviteAdmitsToRoster`: one strongly consistent `GetInvite`, read again on every page, so a revoke ends a walk) gets the member's page, **auth optional** (the token is the credential; auth that is set must be valid; neither is `DENIED`), which is how a web page a link opens shows the roster; an unsigned caller's invite is read before the record. **No ban check** on this path (decided 2026-10-26: nothing ties a token to a user, and a signed banned user could drop the auth; the creator revokes the invite instead). Only a private group is ever served on a token; a member's token is never read; nothing is counted or written. Public groups have an operator kill switch, the `disablePublicGroupRoster` constructor flag: when set, a call naming a public group is `DENIED` after the record read and before the membership read, members included (a result, not a transient error: a group member holds no roster to fall back on, so a retry would only be refused again); DMs and private groups are always served to their members, whatever the flag says. Pointers are hydrated for a DM's participants only, never for group members. **`Member.joined_at` / `Member.version`** are the membership row's join time and version stamp, set for group members on roster pages and on the joiner's own `RosterUpdate.MemberJoined` (`announcedMember`: one strongly consistent `GetGroupMemberRecords` point read after a real join/creation, so the announcement carries the record the write produced), **unset on `Metadata.members`** (the viewer's own entry needs neither, and `hydrate` reads no membership rows) and never for a DM's participants. A departure's version is the `roster_summary.version` on its `MemberLeft`, so a client merges pages against the stream per member by version. **Mention suggestions (`chat/mention.go`).** `GetMentionSuggestions` returns a ranked pool of people to @mention in a group, which the client filters locally: today the group's most recent senders (`GetRecentSenders`), most recent first, **departed users included** (users can't tell them from members). The server decides the size (`mentionSuggestionsSize` 50, reading `mentionSuggestionsSlack` 20 more to absorb filtering); the request carries no limit, and neither size nor ranking is contract. Gate: a DM is `DENIED` before any read, a missing group `NOT_FOUND`, then `Access.CanSpeak` (so a non-creator in a creator-only group is `DENIED`). Dropped after the read: the caller, users the caller blocked (`BlocklistReader.GetBlocked`), users without a username, and users the profile domain no longer knows. A user who blocked the caller **is** still suggested (decided). Each suggestion is a `MentionSuggestion{user_profile, last_sent_at}`; the ranking score is never exposed. diff --git a/chat/invite.go b/chat/invite.go index cb2b5d7..ce8d168 100644 --- a/chat/invite.go +++ b/chat/invite.go @@ -69,13 +69,18 @@ import ( // invites (TOO_MANY_INVITES; maxActiveInvites, MaxActiveInvites in // production) and, when a concurrent create of the same ID won the race, // returns that invite, judged by the same retry rule. -// - GetInvite is any registered user's: the invite for the token -// (NOT_FOUND), its chat's record (NOT_FOUND, which a stored invite never -// outlives), BANNED for a user banned from the chat, and otherwise the -// chat exactly as GetChat shows it to the caller under FULL (see -// chatAsSeenBy): a member's metadata in full, a non-member the record -// with in_lobby. With it goes the invite's status as of now, and nothing -// else of the invite: no use count, no key, nothing written. +// - GetInvite is anyone's who holds the link: the token is the credential, +// so auth is optional, and checked when set. It reads the invite for the +// token (NOT_FOUND), then its chat's record (NOT_FOUND, which a stored +// invite never outlives). An authenticated caller is then BANNED if +// banned from the chat, and otherwise gets the chat exactly as GetChat +// shows it to them under FULL (see chatAsSeenBy): a member's metadata in +// full, a non-member the record with in_lobby. A caller with no auth is +// no one, so is never BANNED, and gets the chat's public view (see +// publicChatMetadata), which for a private group is its record alone; a +// web page that a link opens previews the invite this way. With the chat +// goes the invite's status as of now, and nothing else of the invite: no +// use count, no key, nothing written. // - GetChatInvites pages the chat's invites, every creator's and in every // status, most recently created first (see InvitePosition), up to // maxGetChatInvitesPageSize a page, with an exact has_more and a token @@ -88,6 +93,9 @@ import ( // when no invite to this chat has it, another chat's included), deleting // the invite's copy of the chat key in the same write; a repeat is OK. // +// A token also shows the chat's roster while its invite admits users, with or +// without auth (see GetRoster in roster.go). +// // Nothing here is published: an invite is the inviter's, and its effects // (joins) are announced where they happen. @@ -358,16 +366,21 @@ func createInviteRetry(existing Invite, chatID *commonpb.ChatId, userID *commonp } func (s *Server) GetInvite(ctx context.Context, req *chatpb.GetInviteRequest) (*chatpb.GetInviteResponse, error) { - userID, err := s.authz.Authorize(ctx, req, &req.Auth) - if err != nil { - return nil, err + // Auth is optional (see above): without it, userID stays nil. + var userID *commonpb.UserId + if req.Auth != nil { + var err error + userID, err = s.authz.Authorize(ctx, req, &req.Auth) + if err != nil { + return nil, err + } } inviteID := MustDeriveInviteID(req.InviteToken) - log := s.log.With( - zap.String("user_id", model.UserIDString(userID)), - zap.String("invite_id", hex.EncodeToString(inviteID.Value)), - ) + log := s.log.With(zap.String("invite_id", hex.EncodeToString(inviteID.Value))) + if userID != nil { + log = log.With(zap.String("user_id", model.UserIDString(userID))) + } invite, err := s.chats.GetInvite(ctx, inviteID) switch { @@ -388,6 +401,18 @@ func (s *Server) GetInvite(ctx context.Context, req *chatpb.GetInviteRequest) (* return nil, status.Error(codes.Internal, "") } + if userID == nil { + md, err := s.publicChatMetadata(ctx, log, c) + if err != nil { + return nil, err + } + return &chatpb.GetInviteResponse{ + Result: chatpb.GetInviteResponse_OK, + Chat: md, + Status: invite.StatusAt(time.Now()), + }, nil + } + // A banned user is told that and nothing else, not even which chat. Read // strongly consistent, as every gate reads a restriction. state, err := s.access.viewerState(ctx, c.ID, userID) diff --git a/chat/roster.go b/chat/roster.go index 6fd8976..f0c8549 100644 --- a/chat/roster.go +++ b/chat/roster.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/binary" + "encoding/hex" "errors" "time" @@ -53,11 +54,30 @@ import ( // never are, since group pointer advances are not broadcast and a page of // them would be stale on arrival. // -// The read is a member's alone: who else is in a chat is not shown to anyone -// outside it, so a non-member is denied whatever the group's listener rules -// say of them, including one they would admit to read its messages. -// Membership is decided through the shared Access, off the record for a DM -// and from the store for a group. +// The read is a member's, and otherwise only an invite link's holder's: who +// else is in a chat is not shown to anyone outside it, so a non-member is +// denied whatever the group's listener rules say of them, including one they +// would admit to read its messages. Membership is decided through the shared +// Access, off the record for a DM and from the store for a group. +// +// A link's holder is someone who could join the chat at any moment (see +// invite.go), so the roster is shown to them too: a request carrying the +// token of an invite to the chat that admits users as of now (Invite.AdmitsAt) +// is served the page a member gets. The token is the credential, so auth is +// optional when one is sent, which is how a web page that a link opens shows +// who is in the group. Nothing ties the token to a user: a caller with no +// auth is no one, and a ban is not checked even for one with auth, who could +// as well have sent none. A ban stops a user joining, not reading the roster +// through a link they hold; the creator stops that by revoking the invite. +// The invite is read again on every page, strongly consistent, so a walk +// ends with DENIED once it stops admitting anyone. Only a private group has +// invites, so a token never serves a public group or a DM. A member's token +// is never looked at: they are served as a member. Reading counts no use of +// the invite and writes nothing. +// +// A caller with no auth has their invite read before the chat's record, so a +// token that admits no one costs one point read. Logs carry the invite's ID, +// never the token (see invite.go). // // A public group's roster can be switched off by configuration (see // Server.disablePublicGroupRoster): a call naming one is then DENIED once the @@ -87,12 +107,28 @@ const ( ) func (s *Server) GetRoster(ctx context.Context, req *chatpb.GetRosterRequest) (*chatpb.GetRosterResponse, error) { - userID, err := s.authz.Authorize(ctx, req, &req.Auth) - if err != nil { - return nil, err + // Auth is optional when an invite's token is sent (see above): without + // it, userID stays nil. + var userID *commonpb.UserId + if req.Auth != nil { + var err error + userID, err = s.authz.Authorize(ctx, req, &req.Auth) + if err != nil { + return nil, err + } + } else if req.InviteToken == nil { + return &chatpb.GetRosterResponse{Result: chatpb.GetRosterResponse_DENIED}, nil } - log := s.log.With(zap.String("user_id", model.UserIDString(userID))) + log := s.log.With(zap.String("chat_id", model.ChatIDString(req.ChatId))) + if userID != nil { + log = log.With(zap.String("user_id", model.UserIDString(userID))) + } + var inviteID *chatpb.InviteId + if req.InviteToken != nil { + inviteID = MustDeriveInviteID(req.InviteToken) + log = log.With(zap.String("invite_id", hex.EncodeToString(inviteID.Value))) + } limit := maxGetRosterPageSize if pageSize := req.GetQueryOptions().GetPageSize(); pageSize > 0 && int(pageSize) < limit { @@ -111,6 +147,20 @@ func (s *Server) GetRoster(ctx context.Context, req *chatpb.GetRosterRequest) (* after = &pos } + // A caller with no auth is admitted by the invite alone, read first (see + // above). + var inviteAdmits bool + if userID == nil { + var err error + inviteAdmits, err = s.inviteAdmitsToRoster(ctx, log, req.ChatId, inviteID) + if err != nil { + return nil, err + } + if !inviteAdmits { + return &chatpb.GetRosterResponse{Result: chatpb.GetRosterResponse_DENIED}, nil + } + } + // The canonical record first, as for GetChat: its absence is NOT_FOUND, // and a DM's roster and every chat's rules are on it. c, err := s.chats.GetChatByID(ctx, req.ChatId) @@ -126,12 +176,27 @@ func (s *Server) GetRoster(ctx context.Context, req *chatpb.GetRosterRequest) (* return &chatpb.GetRosterResponse{Result: chatpb.GetRosterResponse_DENIED}, nil } - isMember, err := s.access.IsMemberWithChat(ctx, c, userID) - if err != nil { - log.With(zap.Error(err)).Warn("Failure determining chat membership") - return nil, status.Error(codes.Internal, "") + // A signed caller is admitted as a member, or failing that by their + // invite, read only then: a member's token is never read. + var isMember bool + if userID != nil { + isMember, err = s.access.IsMemberWithChat(ctx, c, userID) + if err != nil { + log.With(zap.Error(err)).Warn("Failure determining chat membership") + return nil, status.Error(codes.Internal, "") + } + if !isMember && inviteID != nil && c.IsPrivate { + inviteAdmits, err = s.inviteAdmitsToRoster(ctx, log, c.ID, inviteID) + if err != nil { + return nil, err + } + } } - if !isMember { + + // Only a private group has invites, which an admitting invite already + // implies; checked off the record all the same, so a token never serves + // any other chat. + if !isMember && !(inviteAdmits && c.IsPrivate) { return &chatpb.GetRosterResponse{Result: chatpb.GetRosterResponse_DENIED}, nil } @@ -159,6 +224,26 @@ func (s *Server) GetRoster(ctx context.Context, req *chatpb.GetRosterRequest) (* return resp, nil } +// inviteAdmitsToRoster reports whether inviteID names an invite to chatID +// that admits users as of now, which shows the roster to a caller who is not +// a member (see above). The read is strongly consistent, so a revoke or a +// last use that completed before it is seen. A gRPC error is returned only +// for a failed read. +func (s *Server) inviteAdmitsToRoster(ctx context.Context, log *zap.Logger, chatID *commonpb.ChatId, inviteID *chatpb.InviteId) (bool, error) { + invite, err := s.chats.GetInvite(ctx, inviteID) + switch { + case errors.Is(err, ErrInviteNotFound): + return false, nil + case err != nil: + log.With(zap.Error(err)).Warn("Failure getting invite") + return false, status.Error(codes.Internal, "") + } + if !bytes.Equal(invite.ChatID.GetValue(), chatID.GetValue()) { + return false, nil + } + return invite.AdmitsAt(time.Now()) == nil, nil +} + // rosterPage reads one page of c's roster in roster order, strictly after // the after position, at most limit members, choosing the shape described // above, and reports whether members follow it. The summary is the one the diff --git a/chat/server.go b/chat/server.go index 3857845..482b3bc 100644 --- a/chat/server.go +++ b/chat/server.go @@ -440,7 +440,8 @@ func (s *Server) GetChat(ctx context.Context, req *chatpb.GetChatRequest) (*chat // chatAsSeenBy is c's metadata as userID sees it under mode, which is what // GetChat returns them (see GetChat), and ok false when they may not see it // at all: a DM they are not a member of. Its other callers return a chat "as -// the caller sees it with GetChat" (see GetInvite). A gRPC error is returned +// the caller sees it with GetChat" (see GetInvite), and use +// publicChatMetadata for a caller with no auth. A gRPC error is returned // only for a failed read. func (s *Server) chatAsSeenBy(ctx context.Context, log *zap.Logger, c *Chat, userID *commonpb.UserId, mode messagingpb.ViewMode) (md *chatpb.Metadata, ok bool, err error) { // The caller's standing is decided off the record where it can be — a DM's @@ -493,15 +494,27 @@ func (s *Server) getPublicChat(ctx context.Context, req *chatpb.GetChatRequest) return nil, status.Error(codes.Internal, "") } - standing := s.access.PublicListenerStanding(c) - metadata, err := s.hydrator.hydrate(ctx, nil, standing, standing.Reading(req.GetViewMode()), fullDetail, []*Chat{c}) + md, err := s.publicChatMetadata(ctx, log, c) if err != nil { - log.With(zap.Error(err)).Warn("Failure hydrating chat metadata") - return nil, status.Error(codes.Internal, "") + return nil, err } return &chatpb.GetChatResponse{ Result: chatpb.GetChatResponse_OK, - Metadata: metadata[0], + Metadata: md, }, nil } + +// publicChatMetadata is the group c's public view (see getPublicChat), which +// is what GetChat returns a caller with no auth. Its other callers return a +// chat's public view (see GetInvite). A gRPC error is returned only for a +// failed read. +func (s *Server) publicChatMetadata(ctx context.Context, log *zap.Logger, c *Chat) (*chatpb.Metadata, error) { + standing := s.access.PublicListenerStanding(c) + metadata, err := s.hydrator.hydrate(ctx, nil, standing, standing.Reading(messagingpb.ViewMode_REDACTED), fullDetail, []*Chat{c}) + if err != nil { + log.With(zap.Error(err)).Warn("Failure hydrating chat metadata") + return nil, status.Error(codes.Internal, "") + } + return metadata[0], nil +} diff --git a/chat/tests/server.go b/chat/tests/server.go index 3b3f2dc..673cc06 100644 --- a/chat/tests/server.go +++ b/chat/tests/server.go @@ -75,6 +75,7 @@ func RunServerTests(t *testing.T, s chat.Store, teardown func()) { testServer_GetRoster_Paging, testServer_GetRoster_Dm, testServer_GetRoster_Gates, + testServer_GetRoster_Invite, testServer_GetRoster_PublicGroupDisabled, testServer_GetMentionSuggestions, testServer_GetMentionSuggestions_Size, @@ -183,6 +184,7 @@ func RunServerTests(t *testing.T, s chat.Store, teardown func()) { testServer_Invite_Cap, testServer_Invite_Retry, testServer_Invite_Get, + testServer_Invite_GetUnauthenticated, testServer_Invite_List, testServer_Invite_Revoke, testServer_Invite_Permissions, @@ -915,6 +917,24 @@ func (e *serverEnv) mustGetRoster(keys model.KeyPair, chatID *commonpb.ChatId, o return resp } +// getRosterWithInvite asks for a roster as keys, sending an invite's token +// (see chat.Server.GetRoster). +func (e *serverEnv) getRosterWithInvite(keys model.KeyPair, chatID *commonpb.ChatId, token *chatpb.InviteToken, opts *commonpb.QueryOptions) *chatpb.GetRosterResponse { + req := &chatpb.GetRosterRequest{ChatId: chatID, QueryOptions: opts, InviteToken: token} + require.NoError(e.t, keys.Auth(req, &req.Auth)) + resp, err := e.client.GetRoster(e.ctx, req) + require.NoError(e.t, err) + return resp +} + +// getPublicRoster asks for a roster with no auth at all, an invite's token +// alone, which may be nil (see chat.Server.GetRoster). +func (e *serverEnv) getPublicRoster(chatID *commonpb.ChatId, token *chatpb.InviteToken, opts *commonpb.QueryOptions) *chatpb.GetRosterResponse { + resp, err := e.client.GetRoster(e.ctx, &chatpb.GetRosterRequest{ChatId: chatID, QueryOptions: opts, InviteToken: token}) + require.NoError(e.t, err) + return resp +} + // memberUserIDs projects a page onto its members' user IDs, in page order. func memberUserIDs(members []*chatpb.Member) [][]byte { out := make([][]byte, len(members)) @@ -1697,6 +1717,126 @@ func testServer_GetRoster_Gates(t *testing.T, s chat.Store) { require.Equal(t, chatpb.GetRosterResponse_DENIED, resp.Result) } +// testServer_GetRoster_Invite pins the roster read with an invite's token: the +// page a member gets, with auth or without, while the invite admits users to +// this chat, and DENIED otherwise, as is a request with neither auth nor a +// token. A ban does not stop it, a member's token is never looked at, the +// invite is read again on every page, and reading counts no use. Auth that +// is set must still be valid. +func testServer_GetRoster_Invite(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + e.bindKey(e.userID, e.keys) + e.accounts.setStaff(e.userID, true) + strangerID, strangerKeys := e.addBoundUser("Stranger") + joinerID, _ := e.addBoundUser("Joiner") + chatID := e.putKeyedPrivateGroup("Sunday Hikers", model.MustGenerateUserID(), model.MustGenerateUserID()) + token, invite := e.mustCreateInvite(e.keys, chatID) + + member := e.mustGetRoster(e.keys, chatID, nil) + require.Equal(t, chatpb.GetRosterResponse_OK, member.Result) + require.Len(t, member.Members, 3) + + // The token alone is served the member's page, and so is a non-member + // with auth who sends it. + require.NoError(t, protoutil.ProtoEqualError(member, e.getPublicRoster(chatID, token, nil))) + require.NoError(t, protoutil.ProtoEqualError(member, e.getRosterWithInvite(strangerKeys, chatID, token, nil))) + + // Reading counts no use. + e.waitForChatInvites(e.keys, chatID, invite) + + // Neither auth nor a token, or a non-member without a token, is DENIED. + resp := e.getPublicRoster(chatID, nil, nil) + require.Equal(t, chatpb.GetRosterResponse_DENIED, resp.Result) + require.Empty(t, resp.Members) + require.Equal(t, chatpb.GetRosterResponse_DENIED, e.mustGetRoster(strangerKeys, chatID, nil).Result) + + // A member's token is never looked at: one that names no invite changes + // nothing. + require.NoError(t, protoutil.ProtoEqualError(member, e.getRosterWithInvite(e.keys, chatID, newInviteToken(), nil))) + + // A token that names no invite, or an invite to another chat, is DENIED. + unknown := newInviteToken() + require.Equal(t, chatpb.GetRosterResponse_DENIED, e.getPublicRoster(chatID, unknown, nil).Result) + require.Equal(t, chatpb.GetRosterResponse_DENIED, e.getRosterWithInvite(strangerKeys, chatID, unknown, nil).Result) + other := e.putKeyedPrivateGroup("Other") + otherToken, _ := e.mustCreateInvite(e.keys, other) + require.Equal(t, chatpb.GetRosterResponse_OK, e.getPublicRoster(other, otherToken, nil).Result) + require.Equal(t, chatpb.GetRosterResponse_DENIED, e.getPublicRoster(chatID, otherToken, nil).Result) + require.Equal(t, chatpb.GetRosterResponse_DENIED, e.getRosterWithInvite(strangerKeys, chatID, otherToken, nil).Result) + + // A token never serves a public group or a DM. + public := e.putGroup("Open", at(1)) + dm := e.putDM(at(1)) + for _, id := range []*commonpb.ChatId{public, dm} { + resp = e.getPublicRoster(id, token, nil) + require.Equal(t, chatpb.GetRosterResponse_DENIED, resp.Result) + require.Empty(t, resp.Members) + require.Equal(t, chatpb.GetRosterResponse_DENIED, e.getRosterWithInvite(strangerKeys, id, token, nil).Result) + } + + // A ban stops a user joining, not reading the roster with a link. + require.Equal(t, chatpb.BanUserResponse_OK, e.mustBanUser(e.keys, chatID, strangerID)) + require.NoError(t, protoutil.ProtoEqualError(member, e.getRosterWithInvite(strangerKeys, chatID, token, nil))) + + // Auth that is set must be valid: a request altered after it was signed + // is refused, not answered off its token. + req := &chatpb.GetRosterRequest{ChatId: chatID} + require.NoError(t, strangerKeys.Auth(req, &req.Auth)) + req.InviteToken = token + _, err := e.client.GetRoster(e.ctx, req) + require.Error(t, err) + + // A walk with the token pages the roster as a member's does. + var walked [][]byte + var pageToken *commonpb.PagingToken + for { + resp = e.getPublicRoster(chatID, token, &commonpb.QueryOptions{PageSize: 1, PagingToken: pageToken}) + require.Equal(t, chatpb.GetRosterResponse_OK, resp.Result) + walked = append(walked, memberUserIDs(resp.Members)...) + if !resp.HasMore { + break + } + pageToken = resp.PagingToken + } + require.Equal(t, memberUserIDs(member.Members), walked) + + // The invite is read again on every page: once revoked, the walk ends. + first := e.getPublicRoster(chatID, token, &commonpb.QueryOptions{PageSize: 1}) + require.True(t, first.HasMore) + require.Equal(t, chatpb.RevokeInviteResponse_OK, e.revokeInvite(e.keys, chatID, invite.InviteId).Result) + resp = e.getPublicRoster(chatID, token, &commonpb.QueryOptions{PageSize: 1, PagingToken: first.PagingToken}) + require.Equal(t, chatpb.GetRosterResponse_DENIED, resp.Result) + require.Empty(t, resp.Members) + require.Equal(t, chatpb.GetRosterResponse_DENIED, e.getRosterWithInvite(strangerKeys, chatID, token, nil).Result) + + // An invite used up admits no one, and so shows no one. + limitedToken := newInviteToken() + limitedReq := createInviteRequest(e.keys, chatID, limitedToken) + limitedReq.MaxUses = 1 + limited := e.createInvite(e.keys, limitedReq) + require.Equal(t, chatpb.CreateInviteResponse_OK, limited.Result) + require.Equal(t, chatpb.GetRosterResponse_OK, e.getPublicRoster(chatID, limitedToken, nil).Result) + _, err = e.store.JoinWithInvite(e.ctx, chatID, joinerID, limited.Invite.InviteId, inviteSecret(3), time.Now()) + require.NoError(t, err) + require.Equal(t, chatpb.GetRosterResponse_DENIED, e.getPublicRoster(chatID, limitedToken, nil).Result) + + // Nor does one past its expiry. Written through the store, which takes an + // expiry in the past, so the read is deterministic. + expiredToken := newInviteToken() + expiresAt := time.Now().Add(-time.Minute) + _, created, err := e.store.CreateInvite(e.ctx, chat.Invite{ + ID: chat.MustDeriveInviteID(expiredToken), + ChatID: chatID, + CreatedBy: e.userID, + CreatedAt: time.Now().Add(-time.Hour), + ExpiresAt: &expiresAt, + SecretEnvelope: inviteSecret(1), + }, signedInviteKeyEnvelope(e.keys, chatID, chat.MustDeriveInviteID(expiredToken)), chat.MaxActiveInvites, time.Now()) + require.NoError(t, err) + require.True(t, created) + require.Equal(t, chatpb.GetRosterResponse_DENIED, e.getPublicRoster(chatID, expiredToken, nil).Result) +} + // testServer_GetRoster_PublicGroupDisabled pins the operator's switch: with // public group rosters disabled, a member of a public group is DENIED, as is // a non-member (the switch is judged before membership), while a DM's participant and a private group's member are @@ -1734,8 +1874,16 @@ func testServer_GetRoster_PublicGroupDisabled(t *testing.T, s chat.Store) { resp = e.mustGetRoster(e.keys, chat.MustGenerateGroupChatID(), nil) require.Equal(t, chatpb.GetRosterResponse_NOT_FOUND, resp.Result) - _, err := e.client.GetRoster(e.ctx, &chatpb.GetRosterRequest{ChatId: group}) - require.Equal(t, codes.InvalidArgument, status.Code(err)) + // Auth that fails is refused on that ground, not by the switch. A request + // with neither auth nor an invite's token is DENIED before anything. + req := &chatpb.GetRosterRequest{ChatId: dm} + require.NoError(t, e.keys.Auth(req, &req.Auth)) + req.ChatId = group + _, err := e.client.GetRoster(e.ctx, req) + require.Error(t, err) + require.NotEqual(t, codes.OK, status.Code(err)) + resp = e.getPublicRoster(group, nil, nil) + require.Equal(t, chatpb.GetRosterResponse_DENIED, resp.Result) } func testServer_GetDmChatFeed_Empty(t *testing.T, s chat.Store) { @@ -7319,6 +7467,14 @@ func (e *serverEnv) getInvite(keys model.KeyPair, token *chatpb.InviteToken) *ch return resp } +// getPublicInvite previews an invite with no auth at all, the token alone +// (see chat.Server.GetInvite). +func (e *serverEnv) getPublicInvite(token *chatpb.InviteToken) *chatpb.GetInviteResponse { + resp, err := e.client.GetInvite(e.ctx, &chatpb.GetInviteRequest{InviteToken: token}) + require.NoError(e.t, err) + return resp +} + func (e *serverEnv) getChatInvites(keys model.KeyPair, chatID *commonpb.ChatId, opts *commonpb.QueryOptions) (*chatpb.GetChatInvitesResponse, error) { req := &chatpb.GetChatInvitesRequest{ChatId: chatID, QueryOptions: opts} require.NoError(e.t, keys.Auth(req, &req.Auth)) @@ -7792,6 +7948,61 @@ func testServer_Invite_Get(t *testing.T, s chat.Store) { require.Equal(t, chatpb.Invite_REVOKED, e.getInvite(e.keys, token).Status) } +// testServer_Invite_GetUnauthenticated pins the preview with no auth, as a web +// page that a link opens asks for it: the token alone gets the chat's public +// view and the invite's status, never BANNED, and counts no use. Auth that is +// set must still be valid. +func testServer_Invite_GetUnauthenticated(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + e.bindKey(e.userID, e.keys) + e.accounts.setStaff(e.userID, true) + bannedID, _ := e.addBoundUser("Banned") + chatID := e.putKeyedPrivateGroup("Sunday Hikers") + + // No invite has the ID of a token never used. + unknown := e.getPublicInvite(newInviteToken()) + require.Equal(t, chatpb.GetInviteResponse_NOT_FOUND, unknown.Result) + require.Nil(t, unknown.Chat) + + token, invite := e.mustCreateInvite(e.keys, chatID) + + // The chat exactly as GetChat shows it with no auth: the record alone. + resp := e.getPublicInvite(token) + require.Equal(t, chatpb.GetInviteResponse_OK, resp.Result) + require.Equal(t, chatpb.Invite_ACTIVE, resp.Status) + require.NoError(t, protoutil.ProtoEqualError(e.getPublicChat(chatID, messagingpb.ViewMode_REDACTED).Metadata, resp.Chat)) + require.Equal(t, chatID.Value, resp.Chat.ChatId.Value) + require.Equal(t, "Sunday Hikers", resp.Chat.Title) + require.True(t, resp.Chat.IsPrivate) + require.Empty(t, resp.Chat.Members) + require.Nil(t, resp.Chat.ViewerState) + require.Nil(t, resp.Chat.LastMessage) + require.False(t, resp.Chat.InLobby) + + // Looking counts no use. + e.waitForChatInvites(e.keys, chatID, invite) + + // A ban names a user, and a caller with no auth is no one. + require.Equal(t, chatpb.BanUserResponse_OK, e.mustBanUser(e.keys, chatID, bannedID)) + resp = e.getPublicInvite(token) + require.Equal(t, chatpb.GetInviteResponse_OK, resp.Result) + require.Equal(t, chatpb.Invite_ACTIVE, resp.Status) + + // Auth that is set must be valid: a request altered after it was signed + // is refused, not answered as if it carried none. + req := &chatpb.GetInviteRequest{InviteToken: token} + require.NoError(t, e.keys.Auth(req, &req.Auth)) + req.InviteToken = newInviteToken() + _, err := e.client.GetInvite(e.ctx, req) + require.Error(t, err) + + // A revoked invite reads REVOKED. + require.Equal(t, chatpb.RevokeInviteResponse_OK, e.revokeInvite(e.keys, chatID, invite.InviteId).Result) + resp = e.getPublicInvite(token) + require.Equal(t, chatpb.GetInviteResponse_OK, resp.Result) + require.Equal(t, chatpb.Invite_REVOKED, resp.Status) +} + // testServer_Invite_List pins GetChatInvites: every invite, newest first, // whatever its status, with its use count and secret envelope, paged with a // token bound to the chat. diff --git a/go.mod b/go.mod index 02ff6d8..88c46f0 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/aws/smithy-go v1.27.7 github.com/buckket/go-blurhash v1.1.0 github.com/cespare/xxhash/v2 v2.3.0 - github.com/code-payments/flipcash2-protobuf-api v1.30.1-0.20261026175705-d0b74f566326 + github.com/code-payments/flipcash2-protobuf-api v1.30.1-0.20261026192314-b655df1770f1 github.com/code-payments/ocp-protobuf-api v1.17.1-0.20261016120110-6f70c0d8e832 github.com/code-payments/ocp-server v1.27.1-0.20261016133142-d1b9b228d45d github.com/devsisters/go-applereceipt v0.0.0-20240805020915-fa22a0160fc2 diff --git a/go.sum b/go.sum index f764cc1..9c8348a 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,8 @@ github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB0 github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= github.com/code-payments/code-vm-indexer v1.2.0 h1:rSHpBMiT9BKgmKcXg/VIoi/h0t7jNxGx07Qz59m+6Q0= github.com/code-payments/code-vm-indexer v1.2.0/go.mod h1:vn91YN2qNqb+gGJeZe2+l+TNxVmEEiRHXXnIn2Y40h8= -github.com/code-payments/flipcash2-protobuf-api v1.30.1-0.20261026175705-d0b74f566326 h1:RPNHvkf54d53KXDHQtmO5llanFgMAzk1/gs+4p8m3Cg= -github.com/code-payments/flipcash2-protobuf-api v1.30.1-0.20261026175705-d0b74f566326/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= +github.com/code-payments/flipcash2-protobuf-api v1.30.1-0.20261026192314-b655df1770f1 h1:rFKHMwujz7xkEhiMbdw9vmGzVxsZefz5YGHlljzbfns= +github.com/code-payments/flipcash2-protobuf-api v1.30.1-0.20261026192314-b655df1770f1/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= github.com/code-payments/ocp-protobuf-api v1.17.1-0.20261016120110-6f70c0d8e832 h1:HFY6mGBaZm8C3tJDK2PvaVQjfUS91NFDDWQSzla80hA= github.com/code-payments/ocp-protobuf-api v1.17.1-0.20261016120110-6f70c0d8e832/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= github.com/code-payments/ocp-server v1.27.1-0.20261016133142-d1b9b228d45d h1:tliC1IE+4TYInxfv3zzd+iGLRq7QQkFaeebIZTdP/os=