From a3d9e14622dea49790e86bc35a2743ec226c2535 Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Thu, 29 Oct 2026 12:31:58 -0400 Subject: [PATCH] Implement message pins on chats --- CLAUDE.md | 12 +- chat/cache/store.go | 24 +- chat/cache/store_test.go | 8 + chat/dynamodb/activity_test.go | 4 +- chat/dynamodb/invite_test.go | 12 +- chat/dynamodb/server_test.go | 5 +- chat/dynamodb/store.go | 544 +++++++++++++++++- chat/dynamodb/store_test.go | 13 +- chat/dynamodb/table.go | 42 +- chat/dynamodb/tombstone_test.go | 4 +- chat/dynamodb/worker_test.go | 4 +- chat/hydrator.go | 85 ++- chat/memory/store.go | 137 +++++ chat/model.go | 8 +- chat/model_test.go | 11 +- chat/pin.go | 724 ++++++++++++++++++++++++ chat/pin_test.go | 81 +++ chat/server.go | 37 ++ chat/store.go | 61 +++ chat/tests/server.go | 884 +++++++++++++++++++++++++++++- chat/tests/store.go | 583 ++++++++++++++++++++ community/server_test.go | 4 + event/tests/server.go | 63 +++ go.mod | 2 +- go.sum | 4 +- messaging/chat.go | 46 +- messaging/chat_test.go | 109 +++- messaging/delta.go | 2 +- messaging/dynamodb/server_test.go | 5 +- messaging/message.go | 43 +- redact/event.go | 97 +++- redact/event_test.go | 130 +++++ 32 files changed, 3652 insertions(+), 136 deletions(-) create mode 100644 chat/pin.go create mode 100644 chat/pin_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 585e53f..dbfffd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,7 @@ Each package represents a bounded context with clear responsibilities: **Core Services (gRPC Servers):** - `account/` - User registration, login, public key management, user flags (flags also carry the `send/` presets to clients as `SendPresets`) - `activity/` - Activity feed for payments, deposits, withdrawals, gift cards. A DM or group payment names the other party by user ID (`intent.IsUserChatPayment` gates both counterparty resolvers), so the payer's entry reads Sent (or Tipped) and the recipient's Received; a group payment to several members (a `SendPublicMultiPayment`) reads Sent too, naming no one, since the payer's record is the total with no counterparty, and each recipient's reads Received naming the payer; a reaction boost is one too: the payer's entry reads Boosted, naming the payee when one is paid (a `SendPublicPayment`) and no one when two are (a `SendPublicMultiPayment`, whose payer record is the total with no counterparty), and each payee's reads Received naming the payer; a requested payment (a debtor paying their share of a payment request, `intent.GetRequestedPayment`) reads Sent for the payer naming the creator and Received for the creator naming the payer; a contact DM payment names them by phone number; a payment with no chat metadata (handed off in person) names no one, and the payer's entry reads Gave. The feed carries no chat reference except a boost's and a requested payment's: both sides' entries for a boost carry `boosted_reaction` (chat, message, emoji; `toBoostedReaction`), and both sides' for a requested payment carry `requested_payment` (the request's group and ID; `toRequestedPayment`), the two cases of the metadata's `context` oneof, each off the intent's app metadata, as paid and never rechecked against the chat or the request (one may since be closed or expired, and a party who has left the group is denied the request on fetch) -- `chat/` - Group/DM chat metadata, membership, feeds (DynamoDB-backed). See "Chat, messaging and events" below. +- `chat/` - Group/DM chat metadata, membership, feeds, pinned messages (DynamoDB-backed). See "Chat, messaging and events" below. - `messaging/` - Message persistence, delivery/read pointers, reactions and reaction boosts (see "Reaction boosts" below), typing notifications (DynamoDB-backed); `sender.go` is the transport-free engine for server-initiated messages (used by `task/` and `flipcashteam/`; `SendBatch` sends several atomically over `PutMessages`, one broadcast, a push per message; `Send` is a batch of one; `NewSender`'s required `teamUserID` (nil for none) makes the team account write-only: its sends don't advance its READ pointer, its DMs are created with it excluded from the feed (the chat store is built excluding the team) so no send moves its inbox row, and `publishChatUpdate` never delivers to it, neither events nor pushes, with a user's message to it skipping push preparation entirely; the push path drops it only after deriving the DM type from the full pair; and nobody messages it: a DM with it carries a `Never` speaker rule (see `chat.RuleEvaluator` under "Rules"), so `Server.canSpeak` refuses every send, edit, deletion and typing notification in one, whoever asks, the team included, and only the `Sender`, which no rule gates, writes there; DM and group payments to or from it are refused at intent validation, so no payment message lands either, and reactions, which gate on `isMember`, are unaffected) - `blob/` - User-uploaded media (images only today). Two S3 buckets (upload → origin) plus CloudFront signed download URLs; the server never proxies bytes. Flow: `GetUploadPolicy` → `InitiateExternalUpload` → client uploads → `CompleteExternalUpload` (queues finalization, returns PROCESSING) → poll `GetBlobs`. `finalizer.go` owns the checkpointed pipeline (inspect → moderate → copy → WebP renditions). Reads: owner always; others need an `AccessContext` whose principal (User, Chat, UserProfile, ChatProfile) holds a grant *and* covers the caller (`access.go`). `image.go` rejects EXIF/privacy metadata. `integration.go` (`ShareIntoChat`, `SetAsProfileMedia`, `SetAsChatMedia` (a group's profile or cover picture: one surface, granted to the chat and the chat-profile principals), `ResolveRenditions`) is the API other domains use. **End-to-end encrypted blobs** (`encrypted.go`; `Blob.EncryptedFor` is a `*Principal` — the surface the READY grant is made to, `PrincipalForChat` for a DM — so the pipeline, stores (`encrypted_for_type` N + `encrypted_for_id` B) and read paths are surface-agnostic and only `initiateEncryptedUpload` maps a proto arm to a principal and its admission gate; `ContentKindEncrypted`, its own finalization queue and so its own `Worker` instance in the parent) are reserved with `InitiateExternalUploadRequest.end_to_end_encrypted_for.chat`: mime must be `application/octet-stream`, size ≤ `MaxEncryptedBlobSizeBytes` (the largest plaintext ceiling across encryptable kinds, today the image one; no allowance for the nonce and tag), and the chat must take encrypted content from the caller (`blob.EncryptedUploadGate`, satisfied by `chat.NewBlobEncryptedUploadGate` over the shared `chat.Access`: an explicit `Access.IsMember` check, then `Access.SpeakerStanding(...).takesEncrypted()`, i.e. the caller is a member who may speak there and the chat takes encrypted content, a **DM** or a **private group that has its key**; the chat package owns the discriminator check and the rule) or the reservation is `DENIED`. Finalization (`finalizeEncrypted`; the `Finalizer` takes the `AccessStore` for this) checks only the declared size (`TOO_LARGE` otherwise), promotes, **grants the chat read access, then** reaches READY, so READY implies granted and a rejected blob is never granted: no inspection, moderation or renditions. Before READY the other members see nothing through `AccessContext.chat` (a reference that outruns READY reads as missing, which is why the sender waits for READY before sending). Metadata carries `kind.encrypted`. Every attach surface refuses one (`validateAttachable`) and `ResolveRenditions` omits it; only `EncryptedContent` in that chat, which the server never reads, references it. Policy always advertises `UploadPolicy.encrypted`; its image bounds (1600px) are advisory by construction. - `poll/` - Polls in group chats, public or private (`pollpb.Poll`: `CreatePoll`, `GetPoll`, `GetPolls`, `Vote`). A poll is shown in its chat as a message carrying a `messagingpb.PollWidget` (the poll's ID alone). ID derived like a group's (`MustDerivePollID`: truncated, domain-separated SHA-256 of creator + `IdempotencyKey`, UUIDv8-stamped), so a retried `CreatePoll` is answered from the stored poll whatever it carries. **Gates (all via the shared `chat.Access`)**: create = `SpeakerStanding` (whose encryption mode also decides the text: plaintext in a public group, `EncryptedPollText` under the chat key in a keyed private group); see = `CanListen` (members, plus a public group's non-members who satisfy its listener rules; a private group's members only); vote = `IsMember` alone, never the speaker rules. **Unsent polls**: a public group's poll is created unsent (`Poll.Live` false), its creator's alone (`NOT_FOUND` to everyone else, no votes), and goes live on its first send (`poll.PrepareSend`, called by messaging before the message is written: only its creator, in its chat, while open), and **is sent at most once**: going live records the send's client message ID on the poll (`Poll.ClientMessageID`, DynamoDB `cmid`, written by `MarkLive`, which refuses a poll live by any other send with `ErrPollAlreadyLive`, so one of racing sends wins), and only a retry of that send passes again, within `SendRetryWindow` (`MaxDuration`) of creation and whether or not the poll is still open, which messaging answers with the original message or, after a send that failed past `MarkLive`, writes the one message; this rests on messaging keeping a client message ID at least that long (`cmidTTL`, pinned by a test), and a deleted poll message is not replaced; one never sent is discarded at its end (TTL `expires_at`, and every read drops it). A private group's poll is live from creation, since its widget travels inside encrypted content. Plaintext is moderated as **one** `ClassifyText` text (question then each option, a line each; any error incl. unsupported language fails the RPC; flagged → `TEXT_MODERATED`). A `BALANCE` poll's mint must be the core mint or an OCP currency in state available, and its currency one with `send/` presets, both checked at creation (`poll/currencies.go`) so no tally can be stuck on parameters it cannot value. **Closing is by time, not a seal** (decided 2026-10-07): a vote is one write of the voter's own `poll_votes` item (never a transaction on the poll item, which would make every concurrent vote in a big group a transaction conflict), accepted while the server's clock is before `ends_at`, with its write abandoned `VoteWriteTimeout` (10s) after that read; the tally takes the poll up `TallyDelay` (1 min) after its end, so every accepted vote has landed; the margin covers clock skew. Only open and closed are stored; TALLYING is the clock's (from `ends_at` until results), and `closed_at` is `ends_at`. **Storage (`poll/dynamodb`)**: `polls` (one item per poll, the definition and results as marshalled protos) and `poll_votes` (pk poll, sk voter); the tally queue is a sparse GSI on `polls` modelled on blob finalization's (`tally_queue`/`tally_due_at`/`tally_attempts`, one unsharded partition; going live enqueues, `Close` dequeues in the same write; the backlog gauges read only the due range, one page capped at `MaxTallyQueueStatsDepth`, so they cost the backlog, not the open polls). **Tally (`poll/tally.go`, `poll.Worker`)**: claims a due poll, counts every vote with no membership read (only a member can vote, so a vote was a member's when cast and counts even if they have since left; decided 2026-10-07), and closes it; `COUNT` shows its totals however few voted (never `WithheldResults`; the voter minimum was removed 2026-10-13, which the proto leaves to the server); `BALANCE` reads each voter's holdings with `balance.GetMintHoldingsForUsers` at tally time and records exact totals, both the mint's own quarks and the value in the poll's currency (ranking is on the quarks, exact where a sum of float values is not), never withheld. Those `BalanceResults` are what is stored, but no reader is sent them: `ResultsProto` (`poll/model.go`) shows a BALANCE poll's results as `RankedResults` alone (each option's competition rank, ties sharing one, and `total_voters`, every voter including those holding none of the mint, recorded on `BalanceResults.total_voters`; no amounts, no per-option voter counts), which is why the totals need no coarsening or voter minimum (removed 2026-10-10). A failed tally is retried under backoff (capped at 10 min) **forever**: results are final, never guessed. No event announces a close; clients refetch after `ends_at`. @@ -169,7 +169,7 @@ This is the most intricate part of the codebase and where most current work happ **Chat IDs.** Length is the type discriminator: 32 bytes = DM (SHA-256 over a domain-separated, sorted, deduped member set, so creation is idempotent and order-independent), 16 bytes = group (server-derived: a truncated, domain-separated SHA-256 over the creator's user ID and the request's required `IdempotencyKey`, so a retried `StartChat` names the same group and is answered from the existing record; stamped as a version 8 UUID so every group ID is UUID-shaped, though the ID is opaque and nothing parses it). DM paths must reject 16-byte IDs and vice versa. `CONTACT_DM` uses the bare legacy hash domain; other DM types append their enum number. A chat type of `UNKNOWN` falls back to `CONTACT_DM` for legacy clients. -**Chat storage (`chat/dynamodb`).** Tables: `chats` (metadata), `dm_inbox` (per-user DM feed rows; GSI `by_type_activity` on a composite `feed` key, legacy `by_activity` GSI still maintained), `group_members` (pk chat, sk user; plus one `#meta` item per group holding `member_count`/`version`, CAS-updated in the same transaction as each transition, with bounded retries on contention). `Chat.Members` is populated only for DMs; groups return empty `Members` and a `RosterSummary{MemberCount, Version}`. Version is *state, not a delta*: each real transition bumps it by exactly one and no-ops leave it alone; clients keep the greater version. DM sends fan `last_activity` into each member's inbox row. A store built with users in `excludedFromFeed` (a required argument of `NewInMemory` / `NewInDynamoDB`, nil for none, nil entries ignored, duplicates collapsed; `chat.FeedExclusions` in `chat/feed.go`) creates every DM with those users **excluded from the feed**; the parent passes the team account, so no flow that creates a DM can leave it in. The exclusion is store-internal, not on `Chat`: decided at creation, recorded on the DM's canonical item as `excluded_from_feed` (a binary set of user IDs, absent when empty; a DM between two excluded users excludes both), and each excluded member's `dm_inbox` row carries neither `feed` nor `last_activity`, so it is in neither GSI and records membership alone (kept because DM `IsMember` reads it). `GetDmFeedPage` never lists the chat for them, and `AdvanceLastMessage` skips their row off the canonical item it already reads, so a process built excluding no one still advances such a DM safely (its condition could never hold on that row, so including it would cancel every advance); opening DMs never writes the newest end of one GSI key and no send moves the team's row; **group sends never fan out** — the group feed is assembled at read time with order computed once and a window of chat IDs carried in the paging token (`maxGroupFeedChats = 1000`), re-checking membership per page. A fourth table, `chat_user_state` (pk user, sk chat), holds `chat.ViewerState`: what a chat records about one user independent of membership — a mute (`muted_until`, epoch seconds, present only while a mute is recorded; an indefinite mute is a store-internal far-future sentinel), the creator's restrictions (`silenced_until`, encoded like `muted_until`; `banned_at`, epoch nanos; see "Moderation" below) and a per-row `version` with the same state-not-delta rule, shared by the mute and the restrictions (the mute methods never touch restriction attributes, nor the restriction methods the mute's). Rows are sparse, never deleted, and survive leaving the chat. The viewer-state methods (`SetMute`, `ClearMute`, `GetViewerStates`, `GetMutedUsers`, `GetMutedUsersPage`, `GetMutedCount`) are part of `chat.Store` itself, not a separate interface. `GetViewerStates` is one strongly consistent `Query` on the user's partition bounded to the requested key range (a page of chats costs a few RCU, not one per key). The chat-scoped read has **two shapes, chosen by size**: `GetMutedUsers` is a key-range `Query` on the sparse `by_muted` GSI (chat, `muted_until`), billed by the active mutes it returns; `GetMutedUsersPage` ranges the inverted `by_user` GSI (chat, user; every record, full projection so future states need no new index) over an inclusive `[lo, hi]` user-ID key range in the same `user#` order as a group's roster, so a fan-out holding one roster page (`GetGroupMembersPage`, a cursor walk of the `group_members` partition in ascending user-ID order) asks for the mutes between that page's first and last user and never holds either whole. `GetMutedCount` reads a per-chat `#meta` item (pk `chat#`, sk `#meta`; carries neither `chat` nor `muted_until`, which keeps it out of both indexes) holding the number of records with a mute *recorded* — moved in the same transaction as a first mute or a clear, never by a replace or a lapse, so it bounds active mutes from above; the fan-out compares it to the roster size to pick a shape. Both GSIs are eventually consistent. A third, sparse GSI `by_restricted` (chat, `restricted_sort` = big-endian restricted-at nanos ‖ user ID, present only while a silence or ban is recorded, set to the time of the latest real silence or ban and removed with the last restriction) lists a group's restricted users most recent first; `CreateTables` leaves an existing table as is, so it is added to the live table out of band before `GetRestrictedUsers` ships. A replaced mute is one conditional `UpdateItem`; a first mute or a clear is a two-item transaction on the `#meta` pattern (version compared, lost race retried from the returned item, `TransactionConflict` backed off). A no-op never creates an item. A fifth table, `chat_activity` (pk chat, sk user; `NewInDynamoDB` / `CreateTables` take its name), holds each group's **activity records** for mention suggestions (`chat.RecentSender` in `chat/model.go`), a fact about the message log, independent of membership: `RecordSend` is an eventually consistent read then one update of `last_sent_at` (epoch ms) and `activity_score`, conditioned on the `last_sent_at` read (a lost race is checked once more against the item the failure returns, and written once more only when its send is the newest by a full interval; a second loss leaves it unrecorded, best effort), throttled to one per `ActivityRecordInterval` (1 min) per user (a throttled send is answered off the read, with no write), never moving backwards, and expiring by TTL `ActivityRetention` (1 year) after it; `GetRecentSenders` is one eventually consistent query on the `by_last_sent_at` LSI, most recent first; `GetLastSentAt` is the eventually consistent point read of one user's record (both at half a strong read's cost; their readers only rank and show people). A second LSI, `by_activity_score`, orders the partition by **activity score** (`chat/activity.go`), a frequency-weighted ordering of recorded sends, read by `GetActiveSenders` for the chatter sample (mention suggestions stay on recency): the log-sum-exp of the send times with a 3-day half-life (`ActivityScoreHalfLife`), each send weighted by the quiet before it over `ActivityScoreFullWeightGap` (1 day, at most one, so it counts **days active, not messages**: an hour-long burst barely counts past its first send), stored as epoch ms, so one send scores its time and the decay never moves a stored score (comparing decayed sums at any moment compares the scores); never below the last send, and capped at `ActivityScoreMaxLead` (7 days) past the send that set it, which no steady pattern reaches at these values (sending daily settles 6.8 days ahead, weekly about 1). `NextActivityScore` is the one formula, shared by every store. A record written before scores had no `activity_score` and so was missing from the LSI until a one-off backfill (done 2026-10) set `activity_score = last_sent_at`; one that somehow still lacks it reads and scores as its `last_sent_at` (`EffectiveActivityScore`, which also lifts a score trailing `last_sent_at`) but is not found by `GetActiveSenders`. Both reads return each record's effective score. `messaging.Sender` records each sender's last message of every group send (`recordGroupSenders`, after the broadcast, best effort; DMs and system messages record nothing); `GetMentionSuggestions` reads `GetRecentSenders` (see "Mention suggestions" below). A sixth table, `chat_key_envelopes` (pk user, sk chat, no index; `NewInDynamoDB` / `CreateTables` take its name), holds each member's **key envelope** for a private group (`chat.KeyEnvelope`: scheme, nonce, ciphertext and `wrapped_by`, the user who stored it; see "Private groups"). `SetKeyEnvelope` is one conditional put, refused when the stored envelope is one the user wrapped themself, with the refusal returning the stored item; `GetKeyEnvelope` is a strongly consistent point read. Like viewer state, the methods are on `chat.Store`, write against the IDs alone and know nothing of the roster. The one tie to the roster is a departure: `RemoveGroupMember` takes a required `discardKeyEnvelope` flag and, when set, deletes the leaver's envelope **in the same transaction** as the membership transition and its `#meta` update (an unconditional third item, so it commits iff the departure happens and a no-op leave deletes nothing). A seventh table, `chat_lobbies` (pk user, sk chat; `NewInDynamoDB` / `CreateTables` take its name), holds each private group's **lobby entries** (`chat.LobbyEntry`: `entered_at` epoch nanos; the sparse `by_chat` **GSI** is hashed on the `sk` itself, the chat, and ranged on `entered_at`, so no attribute repeats the chat and the index pages a lobby earliest first, **eventually consistent** as the proto allows) and two `#meta` count items per entry, the lobby's size under `chat#` and the user's lobby count under `user#` (both omit `entered_at`, the index's range key, so they stay out of it). **Keyed by user, decided 2026-10-05**: a user's own entries, and the future listing of every lobby they wait in, are one strongly consistent query with no index; the price is the creator's page, which a chat-keyed table with an LSI could serve strongly consistent (an LSI cannot gather one chat's entries across user partitions), and the creator reconciles against `LobbyUpdate`s instead. `EnterLobby` is one transaction: a conditional put of the entry, both counts incremented under `attribute_not_exists OR count < cap`, and a `ConditionCheck` that the user's `group_members` row is absent or not joined, so the caps (`chat.LobbyLimits`, `DefaultLobbyLimits` 100 per lobby / 100 lobbies per user, user's choice 2026-10-05) hold under concurrent entries with no read and **a member never gains an entry** (closing the race where a retried `EnterLobby` lands after the creator's admission removed the entry); the cancellation reasons tell `ErrAlreadyMember` (judged first) from an existing entry (the no-op) from `ErrLobbyFull` (judged before) `ErrTooManyLobbies`. `LeaveLobby` is the inverse (conditional delete + two decrements; a missing entry is the no-op). `GetLobbyEntries` is a strongly consistent read of the user's partition (point read for one chat, bounded range query otherwise, like `GetViewerStates`). `AdmitFromLobby` is `transitionMembership`'s join carrying `alongside` the entry's **conditional** delete, both decrements and an unconditional put of the key envelope: `transitionMembership` now admits conditional alongside items and reports a failed one as `alongsideConditionFailed{Index}` (judged after the transition's own no-op and the summary CAS), which `AdmitFromLobby` translates to `ErrNotInLobby`; a user who is already a member is the transition's no-op (`changed` false, nothing written, a leftover entry is theirs to clear). An eighth table, `chat_featured_groups` (pk user, sk `pos#`, no index; `NewInDynamoDB` / `CreateTables` take its name), holds each user's **featured groups** (`chat/featured.go`: an ordered list of up to `MaxFeaturedGroups` (10) **public** group IDs, no membership required, nothing recorded against the group; the store reads no record, so existence and the public-only rule are the setter's (`DENIED` for a private group), and since `IsPrivate` is immutable the list needs no filtering on read; `ValidateFeaturedGroups` refuses a DM ID or a repeat rather than collapsing it). `SetFeaturedGroups` replaces the whole list in one transaction: the `#meta` item (`version`, `featured_count`) compare-and-set on version, a put of every position the new list fills (each stamped with the new version) and a delete of every position past it, retried from a fresh read on a lost CAS; an equal list is the no-op. `GetFeaturedGroups` is one **eventually consistent** query of the partition (it may trail a replace by a moment; the replace's own read of the list stays strongly consistent, since a stale one could skip a needed write as a no-op), verified (every position at `#meta`'s version, `featured_count` of them, contiguous) and re-read up to 3× when it straddles a replace, so a reader never sees a mix of two lists. Rows carry the raw `chat` so a `(chat, pk)` GSI for "who features this group" can be added later; `#meta` omits it. **RPCs (`chat/featured.go`) are on the Chat service**, not Profile (`profilepb` cannot import `chatpb`, which imports it). `SetFeaturedGroups` (auth required) validates (`InvalidArgument` for a DM ID, a repeat or too many), reads the groups' records with `Store.GetGroupChatsByID` (eventually consistent, so a group created a moment ago may briefly be `NOT_FOUND`; no membership check; a TODO marks revisiting its consistency if another reader comes to use it), answers `NOT_FOUND` for a missing group before `DENIED` for a private one, writes, and returns the list hydrated from the records it already read. `GetFeaturedGroups` takes a username alone (resolved with `ProfileReader.GetUserIDByUsername`; `NOT_FOUND` when nobody holds it), auth optional and changing nothing, and drops groups with no record. Both project through `featuredGroupsMetadata`: `hydrate` with no viewer, `ReadingDenied` and `listDetail`, so every caller gets the same list (no viewer fields, no messaging state, no cover); it also drops a private group, which should never be there, and logs a warning if it finds one. Another table, `chat_invites` (`NewInDynamoDB` / `CreateTables` take its name), holds each private group's **invites** (see "Invites" below): one partition per invite (pk `invite#`), sk `#meta` for the record (chat, `created_by`, `max_uses` (0 = unlimited), `use_count`, `created_at` and `invite_expires_at` epoch nanos, `revoked_at`, and `secret_envelope`, the creator's `InviteSecretEnvelope` marshalled whole) and sk `#key` for the invite's copy of the chat key (`key_envelope`, the `InviteKeyEnvelope` marshalled whole; each envelope is one attribute so a field the proto gains is stored with no store change, while a joiner's copy in `chat_key_envelopes` keeps its parts apart like that table's other envelopes), kept apart so key material stays out of every index and so it can carry a TTL (`expires_at`, the invite's expiry rounded up; `CreateTables` turns TTL on). Three sparse GSIs on `#meta` alone, all projecting ALL: `by_chat` (chat, `created_sort`), `by_chat_creator` (chat, `creator_sort` = creator ‖ created nanos ‖ ID; for future member invites, `GetChatInvitesPage`'s optional `createdBy`) and `by_chat_active` (chat, `active_sort` = expiry nanos, or MaxUint64 for none, ‖ ID; removed by a revoke or the last use), which `CreateInvite`'s active cap counts, so the cap is soft (eventually consistent). No version anywhere: every field is monotone and nothing is published. `RevokeInvite` sets `revoked_at` under `attribute_not_exists(revoked_at)` and deletes `#key` in the same write, never conditioned on `use_count`, so a busy leaked link can always be revoked. `JoinWithInvite` is one strongly consistent `BatchGetItem` (the group's `#meta` and the joiner's member row, the invite's `#meta` and `#key`, the joiner's lobby entry) then one membership transition carrying the ban check, the `use_count` compare-and-set from the count read (conditioned on the chat, no revoke, expiry after now; on the last use also REMOVE `active_sort` and delete `#key`), an unconditional put of the joiner's key envelope as the invite's copy, and the lobby entry's conditional delete with both decrements, or a check that none exists; a failed condition past the ban's starts over from a fresh read (`maxInviteJoinAttempts`). A joiner's copy is their row in `chat_key_envelopes` (`invite_id`, `signature` and the joiner's `secret_*` set only on a copy; `wrapped_by` the invite's creator), so `SetKeyEnvelope`'s rule lets the joiner's own envelope replace it and a leave or ban discards it like any envelope. +**Chat storage (`chat/dynamodb`).** Tables: `chats` (metadata), `dm_inbox` (per-user DM feed rows; GSI `by_type_activity` on a composite `feed` key, legacy `by_activity` GSI still maintained), `group_members` (pk chat, sk user; plus one `#meta` item per group holding `member_count`/`version`, CAS-updated in the same transaction as each transition, with bounded retries on contention). `Chat.Members` is populated only for DMs; groups return empty `Members` and a `RosterSummary{MemberCount, Version}`. Version is *state, not a delta*: each real transition bumps it by exactly one and no-ops leave it alone; clients keep the greater version. DM sends fan `last_activity` into each member's inbox row. A store built with users in `excludedFromFeed` (a required argument of `NewInMemory` / `NewInDynamoDB`, nil for none, nil entries ignored, duplicates collapsed; `chat.FeedExclusions` in `chat/feed.go`) creates every DM with those users **excluded from the feed**; the parent passes the team account, so no flow that creates a DM can leave it in. The exclusion is store-internal, not on `Chat`: decided at creation, recorded on the DM's canonical item as `excluded_from_feed` (a binary set of user IDs, absent when empty; a DM between two excluded users excludes both), and each excluded member's `dm_inbox` row carries neither `feed` nor `last_activity`, so it is in neither GSI and records membership alone (kept because DM `IsMember` reads it). `GetDmFeedPage` never lists the chat for them, and `AdvanceLastMessage` skips their row off the canonical item it already reads, so a process built excluding no one still advances such a DM safely (its condition could never hold on that row, so including it would cancel every advance); opening DMs never writes the newest end of one GSI key and no send moves the team's row; **group sends never fan out** — the group feed is assembled at read time with order computed once and a window of chat IDs carried in the paging token (`maxGroupFeedChats = 1000`), re-checking membership per page. A fourth table, `chat_user_state` (pk user, sk chat), holds `chat.ViewerState`: what a chat records about one user independent of membership — a mute (`muted_until`, epoch seconds, present only while a mute is recorded; an indefinite mute is a store-internal far-future sentinel), the creator's restrictions (`silenced_until`, encoded like `muted_until`; `banned_at`, epoch nanos; see "Moderation" below) and a per-row `version` with the same state-not-delta rule, shared by the mute and the restrictions (the mute methods never touch restriction attributes, nor the restriction methods the mute's). Rows are sparse, never deleted, and survive leaving the chat. The viewer-state methods (`SetMute`, `ClearMute`, `GetViewerStates`, `GetMutedUsers`, `GetMutedUsersPage`, `GetMutedCount`) are part of `chat.Store` itself, not a separate interface. `GetViewerStates` is one strongly consistent `Query` on the user's partition bounded to the requested key range (a page of chats costs a few RCU, not one per key). The chat-scoped read has **two shapes, chosen by size**: `GetMutedUsers` is a key-range `Query` on the sparse `by_muted` GSI (chat, `muted_until`), billed by the active mutes it returns; `GetMutedUsersPage` ranges the inverted `by_user` GSI (chat, user; every record, full projection so future states need no new index) over an inclusive `[lo, hi]` user-ID key range in the same `user#` order as a group's roster, so a fan-out holding one roster page (`GetGroupMembersPage`, a cursor walk of the `group_members` partition in ascending user-ID order) asks for the mutes between that page's first and last user and never holds either whole. `GetMutedCount` reads a per-chat `#meta` item (pk `chat#`, sk `#meta`; carries neither `chat` nor `muted_until`, which keeps it out of both indexes) holding the number of records with a mute *recorded* — moved in the same transaction as a first mute or a clear, never by a replace or a lapse, so it bounds active mutes from above; the fan-out compares it to the roster size to pick a shape. Both GSIs are eventually consistent. A third, sparse GSI `by_restricted` (chat, `restricted_sort` = big-endian restricted-at nanos ‖ user ID, present only while a silence or ban is recorded, set to the time of the latest real silence or ban and removed with the last restriction) lists a group's restricted users most recent first; `CreateTables` leaves an existing table as is, so it is added to the live table out of band before `GetRestrictedUsers` ships. A replaced mute is one conditional `UpdateItem`; a first mute or a clear is a two-item transaction on the `#meta` pattern (version compared, lost race retried from the returned item, `TransactionConflict` backed off). A no-op never creates an item. A fifth table, `chat_activity` (pk chat, sk user; `NewInDynamoDB` / `CreateTables` take its name), holds each group's **activity records** for mention suggestions (`chat.RecentSender` in `chat/model.go`), a fact about the message log, independent of membership: `RecordSend` is an eventually consistent read then one update of `last_sent_at` (epoch ms) and `activity_score`, conditioned on the `last_sent_at` read (a lost race is checked once more against the item the failure returns, and written once more only when its send is the newest by a full interval; a second loss leaves it unrecorded, best effort), throttled to one per `ActivityRecordInterval` (1 min) per user (a throttled send is answered off the read, with no write), never moving backwards, and expiring by TTL `ActivityRetention` (1 year) after it; `GetRecentSenders` is one eventually consistent query on the `by_last_sent_at` LSI, most recent first; `GetLastSentAt` is the eventually consistent point read of one user's record (both at half a strong read's cost; their readers only rank and show people). A second LSI, `by_activity_score`, orders the partition by **activity score** (`chat/activity.go`), a frequency-weighted ordering of recorded sends, read by `GetActiveSenders` for the chatter sample (mention suggestions stay on recency): the log-sum-exp of the send times with a 3-day half-life (`ActivityScoreHalfLife`), each send weighted by the quiet before it over `ActivityScoreFullWeightGap` (1 day, at most one, so it counts **days active, not messages**: an hour-long burst barely counts past its first send), stored as epoch ms, so one send scores its time and the decay never moves a stored score (comparing decayed sums at any moment compares the scores); never below the last send, and capped at `ActivityScoreMaxLead` (7 days) past the send that set it, which no steady pattern reaches at these values (sending daily settles 6.8 days ahead, weekly about 1). `NextActivityScore` is the one formula, shared by every store. A record written before scores had no `activity_score` and so was missing from the LSI until a one-off backfill (done 2026-10) set `activity_score = last_sent_at`; one that somehow still lacks it reads and scores as its `last_sent_at` (`EffectiveActivityScore`, which also lifts a score trailing `last_sent_at`) but is not found by `GetActiveSenders`. Both reads return each record's effective score. `messaging.Sender` records each sender's last message of every group send (`recordGroupSenders`, after the broadcast, best effort; DMs and system messages record nothing); `GetMentionSuggestions` reads `GetRecentSenders` (see "Mention suggestions" below). A sixth table, `chat_key_envelopes` (pk user, sk chat, no index; `NewInDynamoDB` / `CreateTables` take its name), holds each member's **key envelope** for a private group (`chat.KeyEnvelope`: scheme, nonce, ciphertext and `wrapped_by`, the user who stored it; see "Private groups"). `SetKeyEnvelope` is one conditional put, refused when the stored envelope is one the user wrapped themself, with the refusal returning the stored item; `GetKeyEnvelope` is a strongly consistent point read. Like viewer state, the methods are on `chat.Store`, write against the IDs alone and know nothing of the roster. The one tie to the roster is a departure: `RemoveGroupMember` takes a required `discardKeyEnvelope` flag and, when set, deletes the leaver's envelope **in the same transaction** as the membership transition and its `#meta` update (an unconditional third item, so it commits iff the departure happens and a no-op leave deletes nothing). A seventh table, `chat_lobbies` (pk user, sk chat; `NewInDynamoDB` / `CreateTables` take its name), holds each private group's **lobby entries** (`chat.LobbyEntry`: `entered_at` epoch nanos; the sparse `by_chat` **GSI** is hashed on the `sk` itself, the chat, and ranged on `entered_at`, so no attribute repeats the chat and the index pages a lobby earliest first, **eventually consistent** as the proto allows) and two `#meta` count items per entry, the lobby's size under `chat#` and the user's lobby count under `user#` (both omit `entered_at`, the index's range key, so they stay out of it). **Keyed by user, decided 2026-10-05**: a user's own entries, and the future listing of every lobby they wait in, are one strongly consistent query with no index; the price is the creator's page, which a chat-keyed table with an LSI could serve strongly consistent (an LSI cannot gather one chat's entries across user partitions), and the creator reconciles against `LobbyUpdate`s instead. `EnterLobby` is one transaction: a conditional put of the entry, both counts incremented under `attribute_not_exists OR count < cap`, and a `ConditionCheck` that the user's `group_members` row is absent or not joined, so the caps (`chat.LobbyLimits`, `DefaultLobbyLimits` 100 per lobby / 100 lobbies per user, user's choice 2026-10-05) hold under concurrent entries with no read and **a member never gains an entry** (closing the race where a retried `EnterLobby` lands after the creator's admission removed the entry); the cancellation reasons tell `ErrAlreadyMember` (judged first) from an existing entry (the no-op) from `ErrLobbyFull` (judged before) `ErrTooManyLobbies`. `LeaveLobby` is the inverse (conditional delete + two decrements; a missing entry is the no-op). `GetLobbyEntries` is a strongly consistent read of the user's partition (point read for one chat, bounded range query otherwise, like `GetViewerStates`). `AdmitFromLobby` is `transitionMembership`'s join carrying `alongside` the entry's **conditional** delete, both decrements and an unconditional put of the key envelope: `transitionMembership` now admits conditional alongside items and reports a failed one as `alongsideConditionFailed{Index}` (judged after the transition's own no-op and the summary CAS), which `AdmitFromLobby` translates to `ErrNotInLobby`; a user who is already a member is the transition's no-op (`changed` false, nothing written, a leftover entry is theirs to clear). An eighth table, `chat_featured_groups` (pk user, sk `pos#`, no index; `NewInDynamoDB` / `CreateTables` take its name), holds each user's **featured groups** (`chat/featured.go`: an ordered list of up to `MaxFeaturedGroups` (10) **public** group IDs, no membership required, nothing recorded against the group; the store reads no record, so existence and the public-only rule are the setter's (`DENIED` for a private group), and since `IsPrivate` is immutable the list needs no filtering on read; `ValidateFeaturedGroups` refuses a DM ID or a repeat rather than collapsing it). `SetFeaturedGroups` replaces the whole list in one transaction: the `#meta` item (`version`, `featured_count`) compare-and-set on version, a put of every position the new list fills (each stamped with the new version) and a delete of every position past it, retried from a fresh read on a lost CAS; an equal list is the no-op. `GetFeaturedGroups` is one **eventually consistent** query of the partition (it may trail a replace by a moment; the replace's own read of the list stays strongly consistent, since a stale one could skip a needed write as a no-op), verified (every position at `#meta`'s version, `featured_count` of them, contiguous) and re-read up to 3× when it straddles a replace, so a reader never sees a mix of two lists. Rows carry the raw `chat` so a `(chat, pk)` GSI for "who features this group" can be added later; `#meta` omits it. **RPCs (`chat/featured.go`) are on the Chat service**, not Profile (`profilepb` cannot import `chatpb`, which imports it). `SetFeaturedGroups` (auth required) validates (`InvalidArgument` for a DM ID, a repeat or too many), reads the groups' records with `Store.GetGroupChatsByID` (eventually consistent, so a group created a moment ago may briefly be `NOT_FOUND`; no membership check; a TODO marks revisiting its consistency if another reader comes to use it), answers `NOT_FOUND` for a missing group before `DENIED` for a private one, writes, and returns the list hydrated from the records it already read. `GetFeaturedGroups` takes a username alone (resolved with `ProfileReader.GetUserIDByUsername`; `NOT_FOUND` when nobody holds it), auth optional and changing nothing, and drops groups with no record. Both project through `featuredGroupsMetadata`: `hydrate` with no viewer, `ReadingDenied` and `listDetail`, so every caller gets the same list (no viewer fields, no messaging state, no cover); it also drops a private group, which should never be there, and logs a warning if it finds one. Another table, `chat_invites` (`NewInDynamoDB` / `CreateTables` take its name), holds each private group's **invites** (see "Invites" below): one partition per invite (pk `invite#`), sk `#meta` for the record (chat, `created_by`, `max_uses` (0 = unlimited), `use_count`, `created_at` and `invite_expires_at` epoch nanos, `revoked_at`, and `secret_envelope`, the creator's `InviteSecretEnvelope` marshalled whole) and sk `#key` for the invite's copy of the chat key (`key_envelope`, the `InviteKeyEnvelope` marshalled whole; each envelope is one attribute so a field the proto gains is stored with no store change, while a joiner's copy in `chat_key_envelopes` keeps its parts apart like that table's other envelopes), kept apart so key material stays out of every index and so it can carry a TTL (`expires_at`, the invite's expiry rounded up; `CreateTables` turns TTL on). Three sparse GSIs on `#meta` alone, all projecting ALL: `by_chat` (chat, `created_sort`), `by_chat_creator` (chat, `creator_sort` = creator ‖ created nanos ‖ ID; for future member invites, `GetChatInvitesPage`'s optional `createdBy`) and `by_chat_active` (chat, `active_sort` = expiry nanos, or MaxUint64 for none, ‖ ID; removed by a revoke or the last use), which `CreateInvite`'s active cap counts, so the cap is soft (eventually consistent). No version anywhere: every field is monotone and nothing is published. `RevokeInvite` sets `revoked_at` under `attribute_not_exists(revoked_at)` and deletes `#key` in the same write, never conditioned on `use_count`, so a busy leaked link can always be revoked. `JoinWithInvite` is one strongly consistent `BatchGetItem` (the group's `#meta` and the joiner's member row, the invite's `#meta` and `#key`, the joiner's lobby entry) then one membership transition carrying the ban check, the `use_count` compare-and-set from the count read (conditioned on the chat, no revoke, expiry after now; on the last use also REMOVE `active_sort` and delete `#key`), an unconditional put of the joiner's key envelope as the invite's copy, and the lobby entry's conditional delete with both decrements, or a check that none exists; a failed condition past the ban's starts over from a fresh read (`maxInviteJoinAttempts`). A joiner's copy is their row in `chat_key_envelopes` (`invite_id`, `signature` and the joiner's `secret_*` set only on a copy; `wrapped_by` the invite's creator), so `SetKeyEnvelope`'s rule lets the joiner's own envelope replace it and a leave or ban discards it like any envelope. Another table, `chat_pins` (`NewInDynamoDB` / `CreateTables` take its name), holds each chat's **pinned messages** (`chat/pin.go`, DMs and groups alike): one partition per chat (pk `chat#`), one item per pin at sk `msg#<20-digit message ID>` (`message_id`, `pinned_by`, `pinned_at` epoch nanos, and `pinned_sort` = big-endian `pinned_at` nanos ‖ message ID) and a `#meta` item holding the `PinSummary` (`version`, `pin_count`, the latest pin's fields, absent while none is pinned; written by the first pin, never deleted, and carrying no `pinned_sort`, so it stays out of the index). The `by_pinned` **LSI** (pk, `pinned_sort`) read descending is `PinPosition` order, so `GetPinsPage` is one strongly consistent key range. A pin or unpin is one `TransactGetItems` of `#meta` and the pin (not a batch read, which could see a racing pin's item without the `#meta` it moved) then one transaction: the pin's conditional put or delete and `#meta` compare-and-set on the version read (a pin also on `pin_count < cap`); an unpin of the latest first reads the top two of the index for the next; a failed condition reads again, a conflict backs off, both within `maxPinAttempts`. An LSI exists only from table creation, so the infrastructure must create the table with it. **Tombstones.** A departed group member's row stays with `state=2`, `left_at`, a 1h TTL and the departure's roster version, so re-joins are idempotent updates and delayed duplicates of an undone join can be distinguished from news. Readers trust `state`, never the clock. "Formerly a member" is not durable. @@ -181,11 +181,11 @@ This is the most intricate part of the codebase and where most current work happ **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`. +**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`), and likewise drops `pin_summary` without reading the pins (see "Pinned messages"), 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`. **Edit (`chat/edit.go`).** `EditChat` changes any of a group's title, description, profile picture, cover picture and, for a public group, minimum balances. **Only the creator, while a member, may edit** (`Chat.PermissionsFor`: `CanEdit = isMember && IsCreator`; DMs and legacy groups with no recorded creator are never editable; a departed creator is `DENIED` until they rejoin). Fields equal to the record are dropped first, so a no-op request (or one that sets nothing) is `OK` from the record with nothing moderated, attached, written or published. What remains runs in `StartChat`'s order — title moderation (`TITLE_MODERATED`), description moderation (`DESCRIPTION_MODERATED`; an empty description clears it and is not moderated), profile picture attach (`PROFILE_PICTURE_BLOB_NOT_ACCEPTED`), cover attach (`COVER_PICTURE_BLOB_NOT_ACCEPTED`), both via `SetAsChatMedia`, then one `Store.EditGroup` write (`GroupEdit`, nil = unchanged, SETs only the named attributes, and REMOVEs the description when it is cleared, so concurrent edits of different fields never clobber; an empty edit is an error; `SetGroupPicture` remains the only way to *clear* a profile picture, and nothing clears a cover). A real change publishes **one event on the chat topic, excluding no one** (the editor's devices included), with one `MetadataUpdate` per field, in proto order: `TitleChanged` / `ProfilePictureChanged` / `DescriptionChanged` / `CoverPictureChanged` / `RulesChanged` (pictures carry the hydrated rendition set, rules the group's full rules). `redact.ChatUpdate` passes all five through. **Rules (decided 2026-10-27)**: `EditChatRequest.rules` is the group's rules **in full** (state, not delta), validated by `RulesEditFromProto` against the record: only a public group with a minimum listener balance (a private group `DENIED` after the creator gate, any other `INVALID_RULES`); only the listener amount may change and a speaker balance be added, removed or changed (same currency and mints, above the listener by at least the minimum transfer value, the floor as at creation; `parseRules` is `RulesFromProto`'s body, which `allowCreator` lets read a `CreatorRequirement`); the staff and creator-only rules must be present exactly when the record has them, in any order; anything else `INVALID_RULES`; equal rules are dropped. The creator must then pass `CanSpeakWithRules` on the rule set the write will produce (`RULES_NOT_SATISFIED`; checked before title moderation; a valuation error is `Internal`, not `INVALID_RULES`, since the currency was priced at creation). The write is `GroupEdit.MinimumBalances` (`GroupMinimumBalances{Listener, Speaker}`, both set together, a nil speaker REMOVEd, so racing edits need no CAS; DynamoDB conditions it on `attribute_exists(min_listener_balance) AND attribute_not_exists(is_private)` as a backstop). Every process evaluates the new rules from its next check (public rules are never cached), except verdicts held in the 30s admission caches. -**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. +**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. `can_pin` (`Permissions.CanPin`) is either member of a DM, and in a group the recorded creator while a member; it gates `PinMessage` and `UnpinMessage` (see "Pinned messages"). **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. @@ -199,6 +199,8 @@ This is the most intricate part of the codebase and where most current work happ **Moderation (`chat/moderation.go`).** A group's creator, **while a member**, can **silence** a user (`SilenceUser` / `UnsilenceUser`; timed or indefinite, a timed one must end in the future, `InvalidArgument` otherwise) and **ban** one (`BanUser` / `UnbanUser`), in **public and private groups** alike, and page who is restricted (`GetRestrictedUsers`). Gate (`moderatedChat`): a DM ID `DENIED` before any read, the record (`NOT_FOUND`), then the RPC's own moderation permission off `Chat.PermissionsFor(...).Moderation` (`CanSilence` for silence/unsilence, `CanBan` for ban/unban, either for the listing); the creator is never a target (`CANNOT_RESTRICT_CREATOR`; unsilencing or unbanning them is the OK no-op). The target must be exactly `model.UserIDSize` bytes (`validTarget`, `InvalidArgument` before any read; the proto admits 1–32); that it names a registered user is not checked, so a restriction on an unheld ID records a row that gates no one. Both restrictions sit on the target's `chat_user_state` row (`ViewerState.Silence`, `BannedAt`, `RestrictedAt`), may be set on a non-member, survive leaving and rejoining, and are **independent**: a ban leaves a silence as it is, and lifting one never lifts the other. **Silence** takes speech, nothing else: `Access.SpeakerStanding` reads the user's viewer state strongly consistent, concurrently with membership, on every group ask, and refuses an active silence (`ActiveSilence(now)`) after the governance's verdict, so the speaker admission cache answers the rules alone and never hides one; it reports `SpeakerStanding.Silenced`, which `Access.CanDelete` (messaging's `DeleteMessage`) turns back into a yes, so a silenced member may delete their own messages. Everything behind the speaker gate is refused (sends, edits, typing, mention suggestions, poll and payment request creation, group payments, encrypted blob uploads); reactions, boosts, votes and requested payments gate on `IsMember` or nothing and are untouched. A real silence or unsilence publishes `ViewerStateChanged` to the target's user topic only, and only while they are a member (a non-member is shown no `ViewerState`); `ViewerState.restrictions.silence` is the record exactly, like the mute. **Ban** (`Store.BanUser`, one transaction off strongly consistent reads, retried on any failed condition): records `banned_at`, and for a member makes the same departure `RemoveGroupMember` does (the key envelope discarded in a private group, the creator never being a target), and for a user waiting in a private group's lobby deletes the entry with both counts, each conditioned on what was read, so a join or entry racing the ban cancels it rather than outliving it. Every way in carries a `ConditionCheck` that no ban is recorded (`AddGroupMembers`, `EnterLobby`, `AdmitFromLobby` → `ErrBanned`; `JoinChat` and `EnterLobby` answer `BANNED`, `AdmitLobbyMember` `NOT_IN_LOBBY`; a banned user who also fails the listener rules gets `RULES_NOT_SATISFIED`, the store being what judges the ban). A departure is announced exactly as a leave: `RosterUpdate.MemberLeft` to the subject, which does not say why (they learn of the ban from a `BANNED` result when they try to return), and `MembershipChanged` on the chat topic; a lobby removal tells the creator like a denial; the target's mute is cleared as on a leave. A banned non-member of a public group is read as `CanPreview` alone (`publicGroupNonMember` reads the ban, strongly consistent, only once the rules or a remembered admission would grant a full read, since that is all a ban takes away, and then on every such ask; a user the rules refuse and every REDACTED read cost no ban read), so full reads and chat previews under FULL are `DENIED` while the redacted view stays, as it does for anyone. Mention suggestions drop banned users (`GetViewerStatesForUsers`, one eventually consistent batch); silenced ones stay. No system message, no push; a user's messages stay; unbanning readmits no one and publishes nothing. **A private group's key is not rotated**: a banned member can fetch nothing more, but holds the key they opened. `GetRestrictedUsers` pages `by_restricted` (cap 100, `limit+1`, token `[version][chat ID][restricted_at nanos][user ID]` bound to the chat), drops users whose only restriction is a lapsed silence and reads on (at most 4 reads, so a page may come back short with `has_more`), shows `silence` only while in force and a ban as `ban` (a `BanState` holding `banned_at`), and hydrates profiles off the row projection, leaving out (and logging) a user with no profile rather than failing, so one unknown ID never stops the creator paging past it. Cost: every group speaker ask, and every non-member read of a public group that would otherwise be full, pays one strongly consistent point read of `chat_user_state`. +**Pinned messages (`chat/pin.go`).** A chat's pins (DMs and groups alike) are messages a member has pinned, owned by the chat, not messaging: shown on `Metadata.pin_summary` (`PinSummary{latest, count, version}`) and paged whole by `GetPinnedMessages`; stored in `chat_pins` (see "Chat storage"), against the IDs alone, the store checking nothing about the chat, the message or the pinner. **Order** is most recently pinned first (`PinPosition`: `pinned_at` desc, then message ID desc); a pin keeps its place: re-pinning a pinned message is the no-op (its original time and pinner stand), unpinning one not pinned is the no-op, and unpin then pin is a fresh pin at the top. **Cap**: `MaxPinnedMessages` (5 for now; clients are told not to hardcode it), named by the server (`WithMaxPinnedMessages` for tests) and enforced by the store in the write, so racing pins never pass it (`TOO_MANY_PINS`; a re-pin at the cap is still the OK no-op, judged before the cap). **Version** is state, not a delta: +1 per real pin or unpin, never on a no-op, 0 for a chat never pinned; an edit or delete of a pinned message moves nothing (it rides the event log). Every store read is strongly consistent. No expiry. **Gate** (`pinnableChat`, shared by pin and unpin): the record (`NOT_FOUND`, a malformed ID included), then `can_pin` as `Chat.PermissionsFor` decides it (`DENIED`), the one place the rule lives: asked first as if the caller were a member, so one who could not pin even then (in a group, anyone but the creator today) costs no membership read, then with membership read (`Access.IsMemberWithChat`). **`PinMessage`** then reads the message in full through `MessagingReader.Messages` (eventually consistent, so a just-sent message may briefly be refused): absent is `MESSAGE_NOT_FOUND`; otherwise every piece of its content must be on the allowlist (`pinnableMessage`: text, cash, reply, media, encrypted, widget), so a deleted message's tombstone, a system message, empty content or any kind this server does not know is `MESSAGE_NOT_ALLOWED` (a kind the proto gains is refused until added); both judged after the gate, so no one who may not pin can probe a chat's message IDs. **`UnpinMessage`** reads no message (unpinning anything not pinned is the OK no-op). Both responses carry the `pin_summary` after the call and nothing else (no `change`; the event reaches the caller too). **`GetPinnedMessages`** is gated like messaging's `GetMessages` (record, then `ListenerStanding.Reading(view_mode)`, `DENIED` when it allows no reading), auth required, pages capped at 100 (`limit+1` for `has_more`), token `[version][chat ID padded to 32][pinned_at nanos][message ID]` bound to the chat (`InvalidArgument` when malformed or another chat's), and returns the page beside the summary, the summary read **first** and the page after it (both strongly consistent), so the page is never older than the summary's version; a change landing between the reads may be in the page too, which a client applying the changes after that version takes as the no-op it is. **Every `Pin` carries its message** (the proto requires it): the messages of every pin a call shows, the summary's latest among them, are read in one batch under the call's reading (`readPinnedMessages`, `pinsToProto`), redacted for a redacted reading, and a pinned message missing from the read **fails the call (`Internal`)** rather than show a pin without it; a message deleted after it was pinned is not missing, its tombstone is shown, and clients hide such a pin. **Nothing unpins on delete** (decided 2026-10-29): a deleted pinned message stays pinned, counting toward the cap, until someone permitted to pin unpins it. **Hydration**: `pin_summary` is set under `fullDetail` (every carrier but the feeds) whenever the viewer's reading is not denied, the zero summary for a chat never pinned; all summaries in one `GetPinSummaries` batch, then every latest message in one `Messages` read; a missing latest fails the hydration. **Events**: a real pin or unpin, never a no-op, publishes one `MetadataUpdate.PinsChanged` (`pinned` the `Pin`, or `unpinned` the message ID, plus the summary after it) to every member, the caller's devices included: a group's chat topic, excluding no one, and each DM member's user topic, skipping the team account (`publishPinsChanged`). Clients apply a change only at their version + 1, else take the summary and refetch. The event is built from the protos the response carries, so when those cannot be built (a message missing) the call is `Internal` and nothing is published; the write has landed, a retry is the no-op, and members converge from their next read. **Messages read for chat** (`chat.MessagingReader.Messages(refs, reading)`, keyed by `chat.MessageKey`; `messaging.NewChatMessagingReader(log, store, media)`) is `GetMessagesByRefs` then `presentMessages`, the same media hydration and redaction messaging's own reads apply, so a pin's message reads exactly as `GetMessages` would show it; `LastMessages` (hydrate's `last_message`, keyed per chat) is separate and unchanged. Not built: unpin on delete, a pin history. + **`chat/cache`** caches what is fixed at creation: DM membership (positives only), DM member lists, and a **private** group's rules read (creator and privacy) forever. A public group's rules are never cached, since an edit by any process may change its minimum balances. Group membership is never cached, and neither is viewer state, a key envelope or a lobby: every `SetMute`/`ClearMute`/`GetViewerStates`/`GetMuted*`, restriction call (`SetSilence`/`ClearSilence`/`BanUser`/`UnbanUser`/`GetRestrictedUsersPage`/`GetViewerStatesForUsers`), `SetKeyEnvelope`/`GetKeyEnvelope`, lobby call and invite call (`CreateInvite`/`GetInvite`/`GetChatInvitesPage`/`RevokeInvite`/`JoinWithInvite`) passes straight through. The one exception is a per-process lower bound on each activity record: `RecordSend` answers a throttled send itself (a failed conditional write is still billed), holding each send it saw recorded for `ActivityRecordInterval`; it is safe because the record only moves forward, so a stale entry can cost a write but never skip one, and a refusal is never held. `GetRecentSenders` and `GetLastSentAt` pass through. **Messaging storage (`messaging/dynamodb`).** `messages` uses one partition per chat (sk `#counter` / `msg#` / `evt#`) so `evt#` is a gapless, strongly consistent range for delta catch-up; each message's idempotency marker is **its own partition** (pk `cmid##`, sk `#marker`) so markers spread across the table instead of loading the chat's, and a send is a single transaction over both. `Store.PutMessages` writes a batch of messages to one chat in that same single transaction (all or nothing, consecutive IDs and event sequences in batch order, nothing interleaved); `PutMessage` is a batch of one in every store. A batch writes **one `evt#` row for its whole run of sends**, keyed by the run's *last* event sequence and carrying `first_event_seq` = its first, with `message_id` = the message sent at that first event (absent = the key, so a single send's row and every pre-existing row is a run of one); `GetEventDelta` expands runs back into events, clipped to `(after, head]` and cut at `limit` events, so a cursor or page boundary may sit inside a run (last-seq keying is what lets the range read find it), and it fails the read on a hole or overlap rather than skip a message. Only sends form runs; edits and deletes are single rows. A batch is at most `MaxMessagesPerPut` (49: DynamoDB's 100-item transaction cap over 2n + 2 rows — counter, a message row and marker per message, one run row), enforced by every backend; the memory store keeps one log entry per event and behaves identically. Idempotency is the batch's: every client message ID already spent is a replay (`created == false`, the originals in batch order), a mix of spent and fresh IDs is `ErrPartialReplay` with nothing written. `GetDelta` pages 100 at a time and returns `RESET_REQUIRED` past 1000 events. `message_pointers`: DMs use a **single `#ptrs` item per chat** with per-member attribute suffixes; groups use one `ptr#` item per member. Group pointer advances are stored but never broadcast (N² fan-out). Idempotency markers (`cmid#`) carry a TTL. Reactions span four tables: `message_reactions` is chat-keyed (`agg##` per emoji with count, **per-emoji version**, `boost_micros` (the emoji's boost total, see "Reaction boosts") and a bounded sample; `meta#` holding the message's own state, today its active-emoji count) so a page of summaries is one range query — a **strongly consistent** one, so a reader who reacts and refreshes never finds their emoji absent with no version to explain it; `message_reactors` is **message-keyed** (`user##`, one row per current reaction, deleted on remove, no tombstone) and **each row sits in exactly one of two indexes**: a plain row carries `emoji_version` and is in the `by_version` LSI, so an emoji's plain reactors page most-recent-first under a strongly consistent read; a boosted row carries `emoji_boost` (`##`, with the add's version moved to `reactor_version` and `boost_micros`) instead and is in the sparse `by_boost` **GSI** (pk, `emoji_boost`), which orders one emoji's boosters most money first under a key range (the emoji leads the key rather than being a filter, since DynamoDB applies `Limit` before a filter) and is eventually consistent (a GSI because it can be added to the live table; `CreateTables` leaves an existing table as-is, so the index is added out of band by the infrastructure terraform, see the rollout order below); `message_reaction_boosts` (pk `chat##`, sk `boost#`; user, emoji, micros, the marshalled amount, ts, version, and an `emoji_version` LSI for a history read not built) is the ledger of every boost, one row per payment, whose conditional put is the replay check; `message_self_reactions` is **viewer-keyed** (pk chat+user, sk `#`, the add's version and `reacted_ts`, i.e. the viewer's own reactor entry; **groups only** — a DM's overlay is answered from the sample, so a DM writes no row and `GetSelfReactions` refuses a DM ID) so a viewer's own reactions across a page of messages — the `self_reactor` overlay for groups — are one strongly consistent range query on their own partition, billed by what the viewer reacted rather than a key probe per aggregate on the page (a GSI would be eventually consistent and outside the transaction; a prefix in `message_reactions` would double the write load on the chat partition that already takes every aggregate CAS). Every add/remove is one transaction that writes the reactor row and its viewer-keyed copy and compare-and-sets the aggregate (and the meta row when an emoji activates or empties) to the exact next state, on the `group_members` `#meta` pattern: no read-back, exact type cap and sample eviction, lost CAS retried from the returned item, transaction conflicts backed off. Reactor rows are stamped with the version that added them; that version is the ordering key and the paging cursor, never `reacted_ts`. `GetReactors` reads the aggregate version *before* the page so the page is never older than the version, then reads **two phases**: the boosted head and the plain tail from `by_version`. **The head comes from the aggregate's sample** (already read, strongly consistent) while the sample holds every booster, judged by comparing its boosters with the row's stored `booster_count` (moved in the same CAS; a stored fact, not a derivation from the eviction order, so a future write that drops a booster and forgets the count fails toward the index). That holds for an emoji with up to `MaxStoredSampleReactors` (16) boosters, since a booster is evicted only when all 17 entries are boosters, a boosted entry is never retracted, and a booster's next boost puts them back at their whole total. Only an emoji with more than 16 boosters reads the head from the `by_boost` GSI (a key range on the emoji, eventually consistent, so there alone a just-landed boost may be missing for a moment); a zero total skips the head entirely. Both sources give the same order under the same cursor, so a walk crossing that size continues. **Rollout order**: deploy, add the GSI to the live table and wait for ACTIVE, then let boosts be recorded, since nothing reads the index until an emoji passes 16 boosters; the 16-byte token (`ReactorPageToken`: the last reactor's boost micros then version, big-endian) says which phase it resumes in (a positive total = the head, `emoji_boost <` it; 0 = the tail, versions below it), and a malformed or legacy token restarts the list. A summary's **wire order** is decided at projection, not by the store: `ReactionSummary.ToProto` sorts by `messaging.ReactionLess` (boost total descending, then count descending, ties by the emoji's UTF-8 bytes, so the order is total for a given state and identical from every backend); stores still return their own by-emoji order and nothing downstream relies on it. @@ -215,7 +217,7 @@ This is the most intricate part of the codebase and where most current work happ **Permissions.** One definition lives in `chat.Access` (`chat/access.go`); past membership, every gate switches once on the chat's `governance` (`governanceDm` / `governancePublicGroup` / `governancePrivateGroup`, from `governanceOf`), each case its own named function (`publicGroupNonMember`; `dmSpeaker` / `publicGroupSpeaker` / `privateGroupSpeaker`), and the `RuleEvaluator` takes a `RuleSet`, which only a chat governed by rules has; the parent builds a single instance and injects it into both the chat and messaging servers, so the admission cache and its TTL are shared. Three gates: `CanListen` (member, or a non-member of a *group* who satisfies its listener rules right now and is not banned — a qualifying user can preview a group before joining), `IsMember` (pointer advances, add/remove reaction: writes that are a member's alone), `CanSpeak` (sends, edits, typing: member plus listener and speaker rules, and in a group not silenced); `CanDelete` (deleting one's own message) is `CanSpeak` with the silence carved out (see "Moderation"). `CanSpeak` is the boolean projection of `Access.SpeakerStanding`, a `chat.SpeakerStanding{CanSpeak, Encryption, Scheme}` that also says what the chat takes from a speaker — `EncryptionNone` (public group: plaintext alone), `EncryptionRequired` (DM: the pairwise scheme alone; keyed private group: the chat-key scheme alone) — so nothing outside `chat/` knows what makes a chat take encrypted content; sends and edits ask for the whole standing, typing/mention suggestions the boolean, deletes `CanDelete`, and the blob gate checks membership explicitly and then asks the unexported `takesEncrypted()`. A caller already holding the canonical record uses `ListenerStandingWithChat` / `IsMemberWithChat` (`GetChat`, the mute gate, chat previews): a DM's membership is decided off the record's inline members with no membership read, a group's is still the store's. A member's read is answered on membership alone and never evaluates a rule; a non-member's read evaluates the listener rules, with a positive-only 30s admission cache per (group, user). A public-group member's send-side verdict is cached the same way (`DefaultSpeakerAdmissionTTL` 30s, `WithSpeakerAdmissionTTL`, a separate cache): membership is still read on every ask, refusals and errors are never held, DMs and private groups are never cached, nor is a public group whose verdict reads nothing beyond its rules (no rules at all, or only `CreatorRequirement`; `RuleSet.speakingReadsState`; a group with listener rules but no speaker rules is still cached, since a speak check evaluates the listener rules); a member whose balance drops or staff flag is revoked keeps speaking until the window closes, and a leaver who rejoins within it speaks on it. The messaging test env turns it off so rule changes show on the next send. A public group with **no** listener rules is **open** (decided 2026-10-07): the empty set is satisfied by everyone, so every registered non-member reads it in full, anyone may join it, and its public view is shown; reads and `JoinChat` agree. The store does not require rules, so legacy or operator-written groups may carry none; a group meant to be members-only carries a listener rule, and one with only speaker rules (e.g. `MinimumSpeakerBalance`) is open to read and join, gated only to speak. An open group's admission is not cached (it costs nothing). A DM never reaches the rules fallback. Membership does not track listener rules: members are grandfathered (see "Rules"). `GetChat` returns a group's record to any registered user; the viewer's `ListenerStanding` decides how much `hydrate` reads (no hydrated member for a non-member, no messaging state for a viewer who cannot read). DMs stay member-only. `GetChat` also takes no auth at all: the group's **public view** (`getPublicChat`, standing from `Access.PublicListenerStanding`, off the record with nothing read), which is exactly a registered non-member's REDACTED read with no per-viewer fields; any other mode, or any DM ID, is `DENIED` before the record is read. -**Redacted reads (`redact/`, `messaging.v1.ViewMode`).** `ListenerStanding` has three levels: `IsMember`, `CanListen` (full read), `CanPreview` (redacted read: any non-member of a public group, whatever its rules say of them; `CanListen` implies it). Every message read (`GetMessage`, `GetMessages`, `GetDelta`, and `GetChat` for `last_message`) carries a `view_mode`; `ListenerStanding.Reading(mode)` resolves it to denied / full / redacted and **never widens** the standing: `FULL` (proto default, legacy contract) is full or `DENIED`; `FULL_OR_REDACTED` is the most the standing allows; `REDACTED` is a placeholder for anyone who may read the chat at all, members included, and **never evaluates the rules** (`Access.ListenerStanding` skips the valuation and the admission cache), so a discovery screen pays nothing per group. Redaction happens at one chokepoint per server — `messaging.Server.present` and the last-message step of `chat.Server.hydrate` — **after** media hydration, so placeholders keep dimensions and blurhash and drop only the download URL; a message that cannot be redacted fails the read (`Internal`), never leaks. `redact.Message`/`redact.Content` are allowlist-built: text/captions/reply bodies become shape-only placeholders seeded by (chat ID, message seq); cash, system and deleted content pass through; reactions are not carried (no message read carries them). The reaction reads (`GetReactionSummary`, `GetReactionSummaries`, `GetReactors`) take no mode and answer anyone with `CanPreview`, gated by `overlayStanding` (a REDACTED-mode standing: no rule evaluated, no admission remembered); a non-member's `self_reactor` is never looked up. Pointer RPCs stay a member's. The event stream serves a redacted viewer only through a **chat preview** (see "Event delivery"): `redact.ChatUpdate` is the stream's chokepoint, walking every message an update carries (event-log mutations, a metadata refresh's `last_message`, a join's `metadata.last_message`), passing the overlays through and leaving payment request updates out (a preview drops an update left bare). +**Redacted reads (`redact/`, `messaging.v1.ViewMode`).** `ListenerStanding` has three levels: `IsMember`, `CanListen` (full read), `CanPreview` (redacted read: any non-member of a public group, whatever its rules say of them; `CanListen` implies it). Every message read (`GetMessage`, `GetMessages`, `GetDelta`, and `GetChat` for `last_message`) carries a `view_mode`; `ListenerStanding.Reading(mode)` resolves it to denied / full / redacted and **never widens** the standing: `FULL` (proto default, legacy contract) is full or `DENIED`; `FULL_OR_REDACTED` is the most the standing allows; `REDACTED` is a placeholder for anyone who may read the chat at all, members included, and **never evaluates the rules** (`Access.ListenerStanding` skips the valuation and the admission cache), so a discovery screen pays nothing per group. Redaction happens at one chokepoint per server — `messaging.Server.present` and the last-message step of `chat.Server.hydrate` — **after** media hydration, so placeholders keep dimensions and blurhash and drop only the download URL; a message that cannot be redacted fails the read (`Internal`), never leaks. `redact.Message`/`redact.Content` are allowlist-built: text/captions/reply bodies become shape-only placeholders seeded by (chat ID, message seq); cash, system and deleted content pass through; reactions are not carried (no message read carries them). The reaction reads (`GetReactionSummary`, `GetReactionSummaries`, `GetReactors`) take no mode and answer anyone with `CanPreview`, gated by `overlayStanding` (a REDACTED-mode standing: no rule evaluated, no admission remembered); a non-member's `self_reactor` is never looked up. Pointer RPCs stay a member's. The event stream serves a redacted viewer only through a **chat preview** (see "Event delivery"): `redact.ChatUpdate` is the stream's chokepoint, walking every message an update carries (event-log mutations, a metadata refresh's `last_message` and `pin_summary.latest`, a join's `metadata.last_message` and `pin_summary.latest`, a `PinsChanged`'s pinned message and summary; an unpin names only a message ID), passing the overlays through and leaving payment request updates out (a preview drops an update left bare). **Event delivery (`event/`).** `Bus` runs each handler on its own goroutine. The stream registry is sharded 64 ways. Two cluster namespaces: `user-events` and `chat-events` (groups only; DM updates fan out per member on the user bus, group updates publish once on the chat topic). Stream keys are prefixed because user and group IDs are both 16 bytes. A session's chat topics **follow membership and are version-gated**: a roster transition applies only if newer than the last one applied for that chat, seeded at stream open from membership records including tombstones. A reconcile sweep re-reads each streaming user's membership every 5 minutes (1s tick, 8 workers, quota-paced). Per-peer outboxes are 4096 deep and drop on overflow; a lagging stream is closed with `ErrStreamLagging`. `ForwardEvents` (cross-server, API-key authed) delivers locally only and never re-resolves ownership. diff --git a/chat/cache/store.go b/chat/cache/store.go index 4d24b1a..ed4de2e 100644 --- a/chat/cache/store.go +++ b/chat/cache/store.go @@ -23,8 +23,8 @@ import ( // mutable — and can be mutated by other processes, which this cache can never // observe — so group membership checks and member lists always defer to the // backing store. The rest of the store is passed straight through, viewer -// state (restrictions included), activity reads, key envelopes and invites -// included: a user's state is theirs, or their group creator's, to change at any time, a group's recent senders change with every send, and +// state (restrictions included), activity reads, key envelopes, invites and +// pins included: a user's state is theirs, or their group creator's, to change at any time, a group's recent senders change with every send, and // both reads are already one strongly consistent query, so nothing of either // is held. // @@ -362,6 +362,26 @@ func (c *Cache) GetFeaturedGroups(ctx context.Context, userID *commonpb.UserId) return c.db.GetFeaturedGroups(ctx, userID) } +// The pin methods pass through: any member a chat permits may pin or unpin, +// from any process, and the reads are what the caller is told of the pins it +// just changed, so nothing of a chat's pins is held. + +func (c *Cache) PinMessage(ctx context.Context, chatID *commonpb.ChatId, messageID *messagingpb.MessageId, pinnedBy *commonpb.UserId, pinnedAt time.Time, maxPins int) (chat.PinSummary, bool, error) { + return c.db.PinMessage(ctx, chatID, messageID, pinnedBy, pinnedAt, maxPins) +} + +func (c *Cache) UnpinMessage(ctx context.Context, chatID *commonpb.ChatId, messageID *messagingpb.MessageId) (chat.PinSummary, bool, error) { + return c.db.UnpinMessage(ctx, chatID, messageID) +} + +func (c *Cache) GetPinSummaries(ctx context.Context, chatIDs []*commonpb.ChatId) (map[string]chat.PinSummary, error) { + return c.db.GetPinSummaries(ctx, chatIDs) +} + +func (c *Cache) GetPinsPage(ctx context.Context, chatID *commonpb.ChatId, after *chat.PinPosition, limit int) ([]chat.Pin, error) { + return c.db.GetPinsPage(ctx, chatID, after, limit) +} + // The invite methods pass through: an invite's use count and revoke change // with every join and revoke, from any process, and its record is read once // per create, preview, join or listing, so there is nothing worth holding. diff --git a/chat/cache/store_test.go b/chat/cache/store_test.go index 62dcabd..0032ae3 100644 --- a/chat/cache/store_test.go +++ b/chat/cache/store_test.go @@ -16,6 +16,7 @@ import ( "github.com/code-payments/flipcash2-server/chat" "github.com/code-payments/flipcash2-server/chat/cache" "github.com/code-payments/flipcash2-server/chat/memory" + "github.com/code-payments/flipcash2-server/chat/tests" "github.com/code-payments/flipcash2-server/model" ) @@ -628,3 +629,10 @@ func TestCache_Invites_PassThrough(t *testing.T) { require.Len(t, page, 1) require.Equal(t, chatpb.Invite_REVOKED, page[0].StatusAt(now)) } + +// TestCache_Pins_PassThrough runs the shared pin tests through the cache: pins +// pass straight through, so the cache behaves as its backing store. Every test +// names chats of its own, so the store needs no reset between them. +func TestCache_Pins_PassThrough(t *testing.T) { + tests.RunPinStoreTests(t, cache.NewInCache(memory.NewInMemory(nil)), func() {}) +} diff --git a/chat/dynamodb/activity_test.go b/chat/dynamodb/activity_test.go index 6e00df2..d32438e 100644 --- a/chat/dynamodb/activity_test.go +++ b/chat/dynamodb/activity_test.go @@ -25,9 +25,9 @@ import ( // read and score as if their score were their last send. func TestChat_ActivityScoreLegacyRecords(t *testing.T) { ctx := context.Background() - require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable)) + require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable)) - testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, nil) + testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable, nil) defer testStore.(*store).reset() groupID := chat.MustGenerateGroupChatID() diff --git a/chat/dynamodb/invite_test.go b/chat/dynamodb/invite_test.go index 199e9f7..7c842a2 100644 --- a/chat/dynamodb/invite_test.go +++ b/chat/dynamodb/invite_test.go @@ -30,8 +30,8 @@ import ( // up; and the table has TTL enabled on the attribute. func TestChat_DynamoDBInviteItems(t *testing.T) { ctx := context.Background() - require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable)) - testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, nil) + require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable)) + testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable, nil) defer testStore.(*store).reset() ttl, err := testEnv.Client.DescribeTimeToLive(ctx, &dynamodb.DescribeTimeToLiveInput{TableName: aws.String(invitesTable)}) @@ -168,8 +168,8 @@ func TestChat_DynamoDBInviteItems(t *testing.T) { // attributes behind in chat_key_envelopes. func TestChat_DynamoDBKeyEnvelopeCopyItem(t *testing.T) { ctx := context.Background() - require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable)) - testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, nil) + require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable)) + testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable, nil) defer testStore.(*store).reset() groupID := chat.MustGenerateGroupChatID() @@ -226,8 +226,8 @@ func TestChat_DynamoDBKeyEnvelopeCopyItem(t *testing.T) { // the copy in the same write; and a member's join, the no-op, writes nothing. func TestChat_DynamoDBInviteJoinItems(t *testing.T) { ctx := context.Background() - require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable)) - testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, nil) + require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable)) + testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable, nil) defer testStore.(*store).reset() now := time.Now() diff --git a/chat/dynamodb/server_test.go b/chat/dynamodb/server_test.go index 58ae375..0cb14dd 100644 --- a/chat/dynamodb/server_test.go +++ b/chat/dynamodb/server_test.go @@ -22,12 +22,13 @@ const ( serverFeaturedGroupsTable = "chat_featured_groups_server_test" serverHoldingsTable = "chat_holdings_server_test" serverInvitesTable = "chat_invites_server_test" + serverPinsTable = "chat_pins_server_test" ) func TestChat_DynamoDBServer(t *testing.T) { - require.NoError(t, CreateTables(context.Background(), testEnv.Client, serverChatsTable, serverDmInboxTable, serverGroupMembersTable, serverUserStateTable, serverActivityTable, serverKeyEnvelopesTable, serverLobbiesTable, serverFeaturedGroupsTable, serverHoldingsTable, serverInvitesTable)) + require.NoError(t, CreateTables(context.Background(), testEnv.Client, serverChatsTable, serverDmInboxTable, serverGroupMembersTable, serverUserStateTable, serverActivityTable, serverKeyEnvelopesTable, serverLobbiesTable, serverFeaturedGroupsTable, serverHoldingsTable, serverInvitesTable, serverPinsTable)) - testStore := NewInDynamoDB(testEnv.Client, serverChatsTable, serverDmInboxTable, serverGroupMembersTable, serverUserStateTable, serverActivityTable, serverKeyEnvelopesTable, serverLobbiesTable, serverFeaturedGroupsTable, serverHoldingsTable, serverInvitesTable, nil) + testStore := NewInDynamoDB(testEnv.Client, serverChatsTable, serverDmInboxTable, serverGroupMembersTable, serverUserStateTable, serverActivityTable, serverKeyEnvelopesTable, serverLobbiesTable, serverFeaturedGroupsTable, serverHoldingsTable, serverInvitesTable, serverPinsTable, nil) teardown := func() { testStore.(*store).reset() } diff --git a/chat/dynamodb/store.go b/chat/dynamodb/store.go index e214eff..c5fe211 100644 --- a/chat/dynamodb/store.go +++ b/chat/dynamodb/store.go @@ -28,7 +28,7 @@ import ( "github.com/code-payments/flipcash2-server/model" ) -// The chat store spans ten tables: +// The chat store spans eleven tables: // // chats pk = "chat#" (one item per chat). Canonical metadata: type, // members (the DM participants; absent for groups), title, creator @@ -344,6 +344,40 @@ import ( // fields it read, which is its compare-and-set, and on the // record existing. A version would be needed only if an invite // could ever be edited. +// +// chat_pins pk = "chat#" (one partition per chat, a DM's or a +// group's; see chat.Pin), holding the chat's pins and their +// summary: +// +// sk = "msg#" (see pinSK), one item per pinned +// message: message_id, pinned_by (the raw user ID, B), pinned_at +// (epoch nanos) and pinned_sort (see pinnedSort), which keys +// lsiByPinned on (pk, pinned_sort). Read descending, the index is +// chat.PinPosition order, so a page of pins is one strongly +// consistent key range, resuming strictly below the position it +// is given whether or not that pin still stands. +// +// sk = "#meta" (see skMeta), the chat's chat.PinSummary: version, +// pin_count and the latest pin's message_id, pinned_by and +// pinned_at, absent while nothing is pinned. It carries no +// pinned_sort, which keeps it out of the index. It is written by +// the chat's first pin and never deleted, so a chat that has had +// pins keeps its version when it has none. +// +// A pin or an unpin is a transactional read of #meta and the +// pin's item (see readPinForWrite), then one transaction: the +// pin's put conditioned on its absence (or delete on its +// presence) and #meta compare-and-set on the version read, with +// a pin also conditioned on the count being under the cap, so the +// cap holds under racing pins with no lock. The compare-and-set is what +// lets the write decide from its read: which pin is the latest +// after an unpin of the latest, found with a read of the top of +// the index, and whether a pin is a no-op or over the cap. A lost +// race reads again (see PinMessage). +// +// The index is an LSI, created with the table: CreateTables +// cannot give one to a table that exists, so the infrastructure +// creates the table with it. const ( // gsiByActivity is the legacy feed index on (pk, last_activity), spanning // all of a user's DM types. Superseded by gsiByTypeActivity; retained until @@ -403,6 +437,12 @@ const ( // items must omit it. gsiLobbyByChat = "by_chat" + // lsiByPinned is the (chat, pinned_sort) LSI on chat_pins: a chat's + // pins in pin order when read descending (see GetPinsPage). It is + // sparse on pinned_sort, which only pins carry: the #meta item must omit + // it. + lsiByPinned = "by_pinned" + // gsiByMintRank is the (mint, rank) index on chat_holdings: one // currency's groups in rank order (see GetTopGroupsByHoldings). Every // item carries both keys, and the full item is projected, since a @@ -449,6 +489,9 @@ const ( // skMeta is the sort key of a group's aggregates item in group_members. skMeta = "#meta" + // pinSKPrefix prefixes a message ID in the chat_pins sk (see pinSK). + pinSKPrefix = "msg#" + // skInviteKey is the sort key of an invite's copy of the chat key. The // invite's record is skMeta. skInviteKey = "#key" @@ -517,6 +560,11 @@ const ( attrActiveSort = "active_sort" // chat_invites #meta: see inviteActiveSort, present while neither revoked nor used up, keying gsiInvitesActive attrSignature = "signature" // chat_key_envelopes copies: the copy's signature (B) attrInviteID = "invite_id" // chat_key_envelopes: the raw invite ID (B) of a copy, absent on a wrapped envelope + attrMessageID = "message_id" // chat_pins: the pinned message's ID, on a pin and on #meta for the latest + attrPinnedBy = "pinned_by" // chat_pins: the raw ID of the user who pinned (B), on a pin and on #meta for the latest + attrPinnedAt = "pinned_at" // chat_pins: epoch nanos of the pin, on a pin and on #meta for the latest + attrPinnedSort = "pinned_sort" // chat_pins: see pinnedSort, on pins alone, keying lsiByPinned + attrPinCount = "pin_count" // chat_pins #meta: how many messages are pinned // Keys of the min_listener_balance map. attrBalanceCurrency = "currency" @@ -587,6 +635,7 @@ type store struct { featuredGroupsTable string holdingsTable string invitesTable string + pinsTable string exclusions chat.FeedExclusions } @@ -595,7 +644,7 @@ type store struct { // creating every DM with a user in excludedFromFeed excluding them from the // feed (see chat.FeedExclusions); nil excludes no one. Use CreateTables to // provision the tables. -func NewInDynamoDB(client *dynamodb.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable string, excludedFromFeed []*commonpb.UserId) chat.Store { +func NewInDynamoDB(client *dynamodb.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable string, excludedFromFeed []*commonpb.UserId) chat.Store { return &store{ exclusions: chat.NewFeedExclusions(excludedFromFeed), client: client, @@ -609,6 +658,7 @@ func NewInDynamoDB(client *dynamodb.Client, chatsTable, dmInboxTable, groupMembe featuredGroupsTable: featuredGroupsTable, holdingsTable: holdingsTable, invitesTable: invitesTable, + pinsTable: pinsTable, } } @@ -4356,6 +4406,496 @@ func (s *store) GetFeaturedGroups(ctx context.Context, userID *commonpb.UserId) return s.readFeaturedGroups(ctx, userID, false) } +// A pin or an unpin contends only with the chat's other pins and unpins, on +// the #meta pattern (see maxMembershipAttempts): a lost compare-and-set reads +// again and retries at once, and a TransactionConflict backs off. Both share +// the budget. +const maxPinAttempts = maxMembershipAttempts + +// A pin's message ID is written in 20 digits, the width of the largest +// uint64, so every message ID fits and the sk order is message order. +const pinSKFormat = pinSKPrefix + "%020d" + +// pinSK is the sk of messageID's pin in its chat's chat_pins partition. +func pinSK(messageID *messagingpb.MessageId) string { + return fmt.Sprintf(pinSKFormat, messageID.Value) +} + +// pinKey is the key of the item at sk in chatID's chat_pins partition: a +// pin (see pinSK) or the chat's #meta. +func pinKey(chatID *commonpb.ChatId, sk string) map[string]types.AttributeValue { + return map[string]types.AttributeValue{ + attrPK: avS(chatPK(chatID)), + attrSK: avS(sk), + } +} + +// pinnedSort is the lsiByPinned range key of a pin at pos: its time's epoch +// nanos, big-endian, then its message ID, big-endian. Read descending, the +// index is then chat.PinPosition order — most recently pinned first, ties to +// the greater message ID — and both fields are fixed width, so a page resumes +// strictly below the key of any position with a key condition alone. The +// pin's fields are kept as attributes of their own too, so nothing parses it. +func pinnedSort(pos chat.PinPosition) []byte { + out := binary.BigEndian.AppendUint64(make([]byte, 0, 16), uint64(pos.PinnedAt.UnixNano())) + return binary.BigEndian.AppendUint64(out, pos.MessageID.Value) +} + +// pinItem is pin as stored in chatID's partition, key and index key +// included. +func pinItem(chatID *commonpb.ChatId, pin chat.Pin) map[string]types.AttributeValue { + item := pinKey(chatID, pinSK(pin.MessageID)) + item[attrMessageID] = avN(pin.MessageID.Value) + item[attrPinnedBy] = avB(pin.PinnedBy.Value) + item[attrPinnedAt] = avInt(pin.PinnedAt.UnixNano()) + item[attrPinnedSort] = avB(pinnedSort(pin.Position())) + return item +} + +// pinFromItem reads a pin's fields off item, a pin's own or #meta's for the +// latest pin. +func pinFromItem(item map[string]types.AttributeValue) (chat.Pin, error) { + messageID, err := parseN(item[attrMessageID]) + if err != nil { + return chat.Pin{}, fmt.Errorf("parsing pin message id: %w", err) + } + pinnedBy := asB(item[attrPinnedBy]) + if len(pinnedBy) != model.UserIDSize { + return chat.Pin{}, fmt.Errorf("pin of message %d has no pinner", messageID) + } + pinnedAt, err := parseInt(item[attrPinnedAt]) + if err != nil { + return chat.Pin{}, fmt.Errorf("parsing pin time: %w", err) + } + if pinnedAt < 0 { + return chat.Pin{}, fmt.Errorf("pin of message %d is before the unix epoch", messageID) + } + return chat.Pin{ + MessageID: &messagingpb.MessageId{Value: messageID}, + PinnedBy: &commonpb.UserId{Value: pinnedBy}, + PinnedAt: time.Unix(0, pinnedAt).UTC(), + }, nil +} + +// pinSummaryFromItem reads a chat's #meta item in chat_pins. The latest +// pin's fields are present exactly while something is pinned. +func pinSummaryFromItem(item map[string]types.AttributeValue) (chat.PinSummary, error) { + version, err := parseN(item[attrVersion]) + if err != nil { + return chat.PinSummary{}, fmt.Errorf("parsing pin version: %w", err) + } + count, err := parseN(item[attrPinCount]) + if err != nil { + return chat.PinSummary{}, fmt.Errorf("parsing pin count: %w", err) + } + if count > math.MaxUint32 { + return chat.PinSummary{}, fmt.Errorf("pin count %d out of range", count) + } + summary := chat.PinSummary{Count: uint32(count), Version: version} + if _, ok := item[attrMessageID]; ok { + latest, err := pinFromItem(item) + if err != nil { + return chat.PinSummary{}, err + } + summary.Latest = &latest + } + if (summary.Latest == nil) != (summary.Count == 0) { + return chat.PinSummary{}, fmt.Errorf("pin summary of %d pins has latest %v", summary.Count, summary.Latest != nil) + } + return summary, nil +} + +// errPinReadConflict is a readPinForWrite cancelled by a pin or unpin of the +// chat in flight, which the caller backs off from and reads again. +var errPinReadConflict = errors.New("pin read conflicted with a write") + +// readPinForWrite is the read a pin or an unpin of messageID in chatID starts +// from: the chat's #meta and the pin's item, returning the summary (the zero +// summary when the chat has never had a pin) and whether the message is +// pinned. It is one TransactGetItems, not a batch read, because the two items +// are written together and a batch read is consistent per item, not as a +// set: it could see a racing pin's item and not the #meta it moved, and answer +// the no-op with a summary that never was. A transactional read is +// serializable with the writes, at twice a strongly consistent read's cost, +// and is cancelled while one is in flight on either item, reported as +// errPinReadConflict. +func (s *store) readPinForWrite(ctx context.Context, chatID *commonpb.ChatId, messageID *messagingpb.MessageId) (chat.PinSummary, bool, error) { + out, err := s.client.TransactGetItems(ctx, &dynamodb.TransactGetItemsInput{ + TransactItems: []types.TransactGetItem{ + {Get: &types.Get{TableName: aws.String(s.pinsTable), Key: pinKey(chatID, skMeta)}}, + {Get: &types.Get{TableName: aws.String(s.pinsTable), Key: pinKey(chatID, pinSK(messageID))}}, + }, + }) + if err != nil { + if reasons, ok := cancellationReasons(err); ok { + codes := make([]string, len(reasons)) + for i, reason := range reasons { + codes[i] = aws.ToString(reason.Code) + } + if isTransactionConflict(codes) { + return chat.PinSummary{}, false, errPinReadConflict + } + } + return chat.PinSummary{}, false, err + } + if len(out.Responses) != 2 { + return chat.PinSummary{}, false, fmt.Errorf("pin read returned %d responses", len(out.Responses)) + } + + var summary chat.PinSummary + if meta := out.Responses[0].Item; len(meta) > 0 { + summary, err = pinSummaryFromItem(meta) + if err != nil { + return chat.PinSummary{}, false, err + } + } + pinned := len(out.Responses[1].Item) > 0 + if pinned && summary.Count == 0 { + return chat.PinSummary{}, false, fmt.Errorf("chat %x has message %d pinned and a summary of no pins", chatID.Value, messageID.Value) + } + return summary, pinned, nil +} + +// pinBackoff waits out *backoff, jittered, and doubles it up to +// membershipBackoffMax, for a pin or unpin that lost to a concurrent write. +func pinBackoff(ctx context.Context, backoff *time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(*backoff + rand.N(*backoff)): + } + *backoff = min(2**backoff, membershipBackoffMax) + return nil +} + +// PinMessage reads the chat's #meta and the pin (see readPinForWrite), which +// decides the no-op and then the cap, and otherwise writes one transaction: +// #meta compare-and-set on the version read (on there being none, for the +// chat's first pin) and on the count being under maxPins, moving the count +// and the version by one and, when the pin comes first in pin order, setting +// it as the latest; and the pin's put, conditioned on no pin of the message. +// The cap condition repeats what the read checked, so the cap holds even if +// the version check ever did not. A failed condition is a pin or unpin that +// landed since the read, and is read again, which may find this pin made, or +// the chat at the cap; a TransactionConflict backs off. The latest is judged +// by position, not taken to be the new pin, since the store accepts any +// pinnedAt. +func (s *store) PinMessage(ctx context.Context, chatID *commonpb.ChatId, messageID *messagingpb.MessageId, pinnedBy *commonpb.UserId, pinnedAt time.Time, maxPins int) (chat.PinSummary, bool, error) { + pin := chat.Pin{MessageID: messageID, PinnedBy: pinnedBy, PinnedAt: pinnedAt} + if err := chat.ValidatePin(chatID, pin); err != nil { + return chat.PinSummary{}, false, err + } + pin.PinnedAt = chat.PinTime(pinnedAt) + if maxPins < 1 { + return chat.PinSummary{}, false, fmt.Errorf("max pins must be at least one") + } + + backoff := membershipBackoffBase + for attempt := 0; attempt < maxPinAttempts; attempt++ { + current, pinned, err := s.readPinForWrite(ctx, chatID, messageID) + if errors.Is(err, errPinReadConflict) { + if err := pinBackoff(ctx, &backoff); err != nil { + return chat.PinSummary{}, false, err + } + continue + } + if err != nil { + return chat.PinSummary{}, false, err + } + // The no-op first, then the cap. + if pinned { + return current, false, nil + } + if int64(current.Count) >= int64(maxPins) { + return chat.PinSummary{}, false, chat.ErrTooManyPins + } + + next := chat.PinSummary{Latest: current.Latest, Count: current.Count + 1, Version: current.Version + 1} + names := map[string]string{ + "#version": attrVersion, + "#count": attrPinCount, + } + values := map[string]types.AttributeValue{ + ":next": avN(next.Version), + ":count": avN(uint64(next.Count)), + ":cap": avN(uint64(maxPins)), + } + update := "SET #version = :next, #count = :count" + if current.Latest == nil || pin.Position().Compare(current.Latest.Position()) < 0 { + next.Latest = &pin + update += ", #message = :message, #by = :by, #at = :at" + names["#message"] = attrMessageID + names["#by"] = attrPinnedBy + names["#at"] = attrPinnedAt + values[":message"] = avN(pin.MessageID.Value) + values[":by"] = avB(pin.PinnedBy.Value) + values[":at"] = avInt(pin.PinnedAt.UnixNano()) + } + condition := "#version = :version" + if current.Version == 0 { + // Version zero is a chat that has never had a pin, so no #meta + // item; the first pin must find none, or it lost to another. + condition = "attribute_not_exists(#version)" + } else { + values[":version"] = avN(current.Version) + } + condition += " AND (attribute_not_exists(#count) OR #count < :cap)" + + transactItems := []types.TransactWriteItem{ + {Update: &types.Update{ + TableName: aws.String(s.pinsTable), + Key: pinKey(chatID, skMeta), + UpdateExpression: aws.String(update), + ConditionExpression: aws.String(condition), + ExpressionAttributeNames: names, + ExpressionAttributeValues: values, + }}, + {Put: &types.Put{ + TableName: aws.String(s.pinsTable), + Item: pinItem(chatID, pin), + ConditionExpression: aws.String("attribute_not_exists(#pk)"), + ExpressionAttributeNames: map[string]string{"#pk": attrPK}, + }}, + } + retry, err := s.writePins(ctx, transactItems, &backoff) + if err != nil { + return chat.PinSummary{}, false, err + } + if !retry { + return next.Clone(), true, nil + } + } + return chat.PinSummary{}, false, fmt.Errorf("pinning message %d in chat %x: attempts exhausted", messageID.Value, chatID.Value) +} + +// UnpinMessage reads the chat's #meta and the pin (see readPinForWrite), +// which decides the no-op, and otherwise writes one transaction: #meta +// compare-and-set on the version read, moving the count down and the version +// up by one, and the pin's delete, conditioned on the pin existing. When the +// pin is the latest, the next latest is found first with a strongly +// consistent read of the top two of lsiByPinned, one of them the pin being +// removed, and is set as the latest, or the latest is removed when there is +// none; a pin or unpin landing between the reads moves the version, so the +// write never commits a latest it read stale. A failed condition reads again; +// a TransactionConflict backs off. +func (s *store) UnpinMessage(ctx context.Context, chatID *commonpb.ChatId, messageID *messagingpb.MessageId) (chat.PinSummary, bool, error) { + if err := chat.ValidatePinChatIDs(chatID); err != nil { + return chat.PinSummary{}, false, err + } + if messageID == nil { + return chat.PinSummary{}, false, fmt.Errorf("message id is required") + } + + backoff := membershipBackoffBase + for attempt := 0; attempt < maxPinAttempts; attempt++ { + current, pinned, err := s.readPinForWrite(ctx, chatID, messageID) + if errors.Is(err, errPinReadConflict) { + if err := pinBackoff(ctx, &backoff); err != nil { + return chat.PinSummary{}, false, err + } + continue + } + if err != nil { + return chat.PinSummary{}, false, err + } + if !pinned { + return current, false, nil + } + + next := chat.PinSummary{Latest: current.Latest, Count: current.Count - 1, Version: current.Version + 1} + names := map[string]string{ + "#version": attrVersion, + "#count": attrPinCount, + } + values := map[string]types.AttributeValue{ + ":version": avN(current.Version), + ":next": avN(next.Version), + ":count": avN(uint64(next.Count)), + } + update := "SET #version = :next, #count = :count" + if current.Latest.MessageID.Value == messageID.Value { + top, err := s.queryPins(ctx, chatID, nil, 2) + if err != nil { + return chat.PinSummary{}, false, err + } + next.Latest = nil + for _, pin := range top { + if pin.MessageID.Value != messageID.Value { + next.Latest = &pin + break + } + } + names["#message"] = attrMessageID + names["#by"] = attrPinnedBy + names["#at"] = attrPinnedAt + if next.Latest != nil { + update += ", #message = :message, #by = :by, #at = :at" + values[":message"] = avN(next.Latest.MessageID.Value) + values[":by"] = avB(next.Latest.PinnedBy.Value) + values[":at"] = avInt(next.Latest.PinnedAt.UnixNano()) + } else { + update += " REMOVE #message, #by, #at" + } + } + + transactItems := []types.TransactWriteItem{ + {Update: &types.Update{ + TableName: aws.String(s.pinsTable), + Key: pinKey(chatID, skMeta), + UpdateExpression: aws.String(update), + ConditionExpression: aws.String("#version = :version"), + ExpressionAttributeNames: names, + ExpressionAttributeValues: values, + }}, + {Delete: &types.Delete{ + TableName: aws.String(s.pinsTable), + Key: pinKey(chatID, pinSK(messageID)), + ConditionExpression: aws.String("attribute_exists(#pk)"), + ExpressionAttributeNames: map[string]string{"#pk": attrPK}, + }}, + } + retry, err := s.writePins(ctx, transactItems, &backoff) + if err != nil { + return chat.PinSummary{}, false, err + } + if !retry { + return next.Clone(), true, nil + } + } + return chat.PinSummary{}, false, fmt.Errorf("unpinning message %d in chat %x: attempts exhausted", messageID.Value, chatID.Value) +} + +// writePins writes a pin's or an unpin's transaction, reporting retry true +// when it was cancelled by a failed condition (a write that landed since the +// read; the caller reads again at once) or by a TransactionConflict (after +// backing off, see pinBackoff), and an error for any other failure. +func (s *store) writePins(ctx context.Context, transactItems []types.TransactWriteItem, backoff *time.Duration) (bool, error) { + _, err := s.client.TransactWriteItems(ctx, &dynamodb.TransactWriteItemsInput{TransactItems: transactItems}) + if err == nil { + return false, nil + } + reasons, ok := cancellationReasons(err) + if !ok || len(reasons) != len(transactItems) { + return false, err + } + codes := make([]string, len(reasons)) + for i, reason := range reasons { + codes[i] = aws.ToString(reason.Code) + } + switch { + case slices.Contains(codes, conditionalCheckFailedCode): + return true, nil + case isTransactionConflict(codes): + if err := pinBackoff(ctx, backoff); err != nil { + return false, err + } + return true, nil + default: + return false, err + } +} + +// GetPinSummaries is one strongly consistent batch read of the chats' #meta +// items, which a chat has from its first pin on. +func (s *store) GetPinSummaries(ctx context.Context, chatIDs []*commonpb.ChatId) (map[string]chat.PinSummary, error) { + if err := chat.ValidatePinChatIDs(chatIDs...); err != nil { + return nil, err + } + + seen := make(map[string]struct{}, len(chatIDs)) + var keys []map[string]types.AttributeValue + for _, chatID := range chatIDs { + if _, dup := seen[string(chatID.Value)]; dup { + continue + } + seen[string(chatID.Value)] = struct{}{} + keys = append(keys, pinKey(chatID, skMeta)) + } + + out := make(map[string]chat.PinSummary, len(keys)) + err := s.batchGet(ctx, s.pinsTable, keys, "", nil, true, func(item map[string]types.AttributeValue) error { + chatID, err := chatIDFromPK(item) + if err != nil { + return err + } + summary, err := pinSummaryFromItem(item) + if err != nil { + return err + } + out[string(chatID.Value)] = summary + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +// GetPinsPage is a strongly consistent descending range on lsiByPinned, +// which holds exactly the chat's pins in pin order, resuming below the key +// of the position it is given (see pinnedSort). +func (s *store) GetPinsPage(ctx context.Context, chatID *commonpb.ChatId, after *chat.PinPosition, limit int) ([]chat.Pin, error) { + if err := chat.ValidatePinChatIDs(chatID); err != nil { + return nil, err + } + if after != nil { + if err := chat.ValidatePinPosition(*after); err != nil { + return nil, err + } + } + return s.queryPins(ctx, chatID, after, limit) +} + +// queryPins reads up to limit of chatID's pins strictly after the given +// position (from the start for nil) off lsiByPinned, strongly consistent; a +// limit of zero or less reads them all. +func (s *store) queryPins(ctx context.Context, chatID *commonpb.ChatId, after *chat.PinPosition, limit int) ([]chat.Pin, error) { + condition := "#pk = :pk" + names := map[string]string{"#pk": attrPK} + values := map[string]types.AttributeValue{":pk": avS(chatPK(chatID))} + if after != nil { + condition += " AND #sort < :after" + names["#sort"] = attrPinnedSort + values[":after"] = avB(pinnedSort(*after)) + } + + pins := make([]chat.Pin, 0) + var startKey map[string]types.AttributeValue + for { + input := &dynamodb.QueryInput{ + TableName: aws.String(s.pinsTable), + IndexName: aws.String(lsiByPinned), + KeyConditionExpression: aws.String(condition), + ExpressionAttributeNames: names, + ExpressionAttributeValues: values, + ScanIndexForward: aws.Bool(false), + ConsistentRead: aws.Bool(true), + ExclusiveStartKey: startKey, + } + if limit > 0 { + input.Limit = aws.Int32(int32(min(limit-len(pins), math.MaxInt32))) + } + out, err := s.client.Query(ctx, input) + if err != nil { + return nil, err + } + for _, item := range out.Items { + pin, err := pinFromItem(item) + if err != nil { + return nil, err + } + pins = append(pins, pin) + if limit > 0 && len(pins) == limit { + return pins, nil + } + } + if len(out.LastEvaluatedKey) == 0 { + return pins, nil + } + startKey = out.LastEvaluatedKey + } +} + // readFeaturedGroups reads userID's featured groups: one query of their // partition, strongly consistent when consistent is set, #meta at its head // and the positions after it in order. A query is consistent per item, not diff --git a/chat/dynamodb/store_test.go b/chat/dynamodb/store_test.go index 4e37568..514a04b 100644 --- a/chat/dynamodb/store_test.go +++ b/chat/dynamodb/store_test.go @@ -30,19 +30,20 @@ const ( featuredGroupsTable = "chat_featured_groups_test" holdingsTable = "chat_holdings_test" invitesTable = "chat_invites_test" + pinsTable = "chat_pins_test" ) func TestChat_DynamoDBStore(t *testing.T) { - require.NoError(t, CreateTables(context.Background(), testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable)) + require.NoError(t, CreateTables(context.Background(), testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable)) - testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, nil) + testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable, nil) teardown := func() { testStore.(*store).reset() } // The stores newStore builds share testStore's tables, so its teardown // resets theirs too. newStore := func(excludedFromFeed []*commonpb.UserId) chat.Store { - return NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, excludedFromFeed) + return NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable, excludedFromFeed) } tests.RunStoreTests(t, testStore, newStore, teardown) } @@ -54,11 +55,11 @@ func TestChat_DynamoDBStore(t *testing.T) { // the other's. func TestChat_DynamoDBExclusionOutlivesConfig(t *testing.T) { ctx := context.Background() - require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable)) + require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable)) team := model.MustGenerateUserID() - configured := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, []*commonpb.UserId{team}) - unconfigured := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, nil) + configured := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable, []*commonpb.UserId{team}) + unconfigured := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable, nil) t.Cleanup(func() { configured.(*store).reset() }) chatIDValue := make([]byte, chat.DmChatIDSize) diff --git a/chat/dynamodb/table.go b/chat/dynamodb/table.go index 7f87125..55a94c5 100644 --- a/chat/dynamodb/table.go +++ b/chat/dynamodb/table.go @@ -12,8 +12,8 @@ import ( // CreateTables provisions the chats, dm_inbox, group_members, // chat_user_state, chat_activity, chat_key_envelopes, chat_lobbies, -// chat_featured_groups, chat_holdings and chat_invites tables with on-demand -// billing. The chats table is keyed by pk only; dm_inbox is keyed by (pk, sk) with a GSI ordering each user's DMs +// chat_featured_groups, chat_holdings, chat_invites and chat_pins tables with +// on-demand billing. The chats table is keyed by pk only; dm_inbox is keyed by (pk, sk) with a GSI ordering each user's DMs // by last_activity; group_members is keyed by (pk, sk) = (chat, user) — plus // one "#meta" aggregates item per group — with an inverted GSI for listing a // user's group chats, a sparse GSI of a group's joined members by join time @@ -38,12 +38,15 @@ import ( // invites by creation (gsiInvitesByChat), one creator's invites to a chat by // creation (gsiInvitesByChatCreator) and a chat's invites that are neither // revoked nor used up by expiry (gsiInvitesActive), and TTL on expires_at, -// which only an invite's copy of the chat key carries. It is idempotent and +// which only an invite's copy of the chat key carries; chat_pins is keyed by +// (pk, sk) = (chat, message) — plus one "#meta" summary item per chat — with +// an LSI of a chat's pins in pin order (see lsiByPinned), which, being an LSI, +// exists only if the table was created with it. It is idempotent and // blocks until all tables are ACTIVE. It creates missing tables only: a table that // exists is left as it is, and so is never given an index added to its // definition here since it was created — gsiRestricted on a live // chat_user_state is added out of band (see the store's doc). -func CreateTables(ctx context.Context, client *dynamodb.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable string) error { +func CreateTables(ctx context.Context, client *dynamodb.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable string) error { inputs := []*dynamodb.CreateTableInput{ { TableName: aws.String(chatsTable), @@ -386,6 +389,34 @@ func CreateTables(ctx context.Context, client *dynamodb.Client, chatsTable, dmIn }, }, }, + { + TableName: aws.String(pinsTable), + BillingMode: types.BillingModePayPerRequest, + AttributeDefinitions: []types.AttributeDefinition{ + {AttributeName: aws.String(attrPK), AttributeType: types.ScalarAttributeTypeS}, + {AttributeName: aws.String(attrSK), AttributeType: types.ScalarAttributeTypeS}, + {AttributeName: aws.String(attrPinnedSort), AttributeType: types.ScalarAttributeTypeB}, + }, + KeySchema: []types.KeySchemaElement{ + {AttributeName: aws.String(attrPK), KeyType: types.KeyTypeHash}, + {AttributeName: aws.String(attrSK), KeyType: types.KeyTypeRange}, + }, + // An LSI, not a GSI: a caller that pins reads the chat's pins + // right after, and only a local index serves a strongly + // consistent read. It cannot be added to the table later, so + // it is created with it. Sparse on pinned_sort, which pins + // alone carry: the #meta item must omit it, or it leaks in. + LocalSecondaryIndexes: []types.LocalSecondaryIndex{ + { + IndexName: aws.String(lsiByPinned), + KeySchema: []types.KeySchemaElement{ + {AttributeName: aws.String(attrPK), KeyType: types.KeyTypeHash}, + {AttributeName: aws.String(attrPinnedSort), KeyType: types.KeyTypeRange}, + }, + Projection: &types.Projection{ProjectionType: types.ProjectionTypeAll}, + }, + }, + }, } for _, input := range inputs { @@ -472,6 +503,9 @@ func (s *store) reset() { if err := clearTable(ctx, s.client, s.invitesTable, []string{attrPK, attrSK}); err != nil { panic(err) } + if err := clearTable(ctx, s.client, s.pinsTable, []string{attrPK, attrSK}); err != nil { + panic(err) + } } func clearTable(ctx context.Context, client *dynamodb.Client, table string, keyAttrs []string) error { diff --git a/chat/dynamodb/tombstone_test.go b/chat/dynamodb/tombstone_test.go index ff15014..d0d8704 100644 --- a/chat/dynamodb/tombstone_test.go +++ b/chat/dynamodb/tombstone_test.go @@ -27,9 +27,9 @@ import ( // any of these would delete live memberships silently. func TestChat_TombstoneTTL(t *testing.T) { ctx := context.Background() - require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable)) + require.NoError(t, CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable)) - testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, nil) + testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable, nil) defer testStore.(*store).reset() ttl, err := testEnv.Client.DescribeTimeToLive(ctx, &dynamodb.DescribeTimeToLiveInput{TableName: aws.String(groupMembersTable)}) diff --git a/chat/dynamodb/worker_test.go b/chat/dynamodb/worker_test.go index 77718e3..b339926 100644 --- a/chat/dynamodb/worker_test.go +++ b/chat/dynamodb/worker_test.go @@ -12,9 +12,9 @@ import ( ) func TestChat_DynamoDBHoldingsWorker(t *testing.T) { - require.NoError(t, CreateTables(context.Background(), testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable)) + require.NoError(t, CreateTables(context.Background(), testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable)) - testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, nil) + testStore := NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable, nil) teardown := func() { testStore.(*store).reset() } diff --git a/chat/hydrator.go b/chat/hydrator.go index 1f67fe5..54a78a5 100644 --- a/chat/hydrator.go +++ b/chat/hydrator.go @@ -134,20 +134,23 @@ func (h *Hydrator) publicGroupsMetadata(ctx context.Context, log *zap.Logger, ch type metadataDetail int const ( - // fullDetail is the whole record, for a caller showing one chat — GetChat, - // a creation, an edit, a join, a lobby — or carrying it to a client that - // may. + // fullDetail is the whole record, and the chat's pin summary beside it, + // for a caller showing one chat — GetChat, a creation, an edit, a join, a + // lobby — or carrying it to a client that may. fullDetail metadataDetail = iota // listDetail is the record as a list view shows it: everything but the - // group's cover picture, which only its profile view shows. A feed page - // is many chats a client renders as rows, and the cover's renditions cost - // the server a resolve and a download URL per group for a field no row - // renders. The description stays: a row may show it, and it is short text - // read with the record, costing nothing to carry. The proto allows a feed - // to omit the cover (see chat.v1.Metadata.cover_picture), and tells the - // client to fetch it with GetChat and never to let a feed result clear - // what it holds. + // group's cover picture, which only its profile view shows, and the + // chat's pin summary, which only the conversation shows. A feed page is + // many chats a client renders as rows, and the cover's renditions cost + // the server a resolve and a download URL per group, and the pin summary + // a read of the chat's pins and of its latest pinned message, for fields + // no row renders. The description stays: a row may show it, and it is + // short text read with the record, costing nothing to carry. The proto + // allows a list view to omit the cover and the pin summary (see + // chat.v1.Metadata.cover_picture and pin_summary), and tells the client to + // fetch them with GetChat and never to let a list result clear what it + // holds. listDetail ) @@ -172,13 +175,16 @@ const ( // roster, and a pointer stored for them — a former member's — is not // theirs to see. A DM's members are its record's, whoever the viewer is. // - A viewer whose reading is denied gets no messaging state: no last -// message, no head event sequence, no pointers. The record's own fields — -// title, description, pictures, rules, roster summary, last activity — -// are hydrated for anyone the caller admits to the record at all. +// message, no head event sequence, no pointers, no pin summary. The +// record's own fields — title, description, pictures, rules, roster +// summary, last activity — are hydrated for anyone the caller admits to +// the record at all. // - A viewer whose reading is redacted gets the messaging state with the // last message redacted (see redact.Message), after its media is -// resolved, so the placeholder carries the blurhash a client renders. The -// read fails rather than returning a message that cannot be redacted. +// resolved, so the placeholder carries the blurhash a client renders, and +// the latest pinned message redacted by the read that returns it (see +// MessagingReader.Messages). The read fails rather than returning a +// message that cannot be redacted. // // A DM's members are its two participants, carried on the canonical record. A // group's roster lives in its own records and is not enumerated here: the @@ -244,9 +250,20 @@ const ( // to treat as unavailable, rather than failing the whole read. // // The detail is how much of the record is returned (see metadataDetail): a -// feed asks for listDetail, and its chats carry no cover picture — its -// renditions are never resolved, not resolved and dropped — while every other -// caller asks for fullDetail. +// feed asks for listDetail, and its chats carry no cover picture and no pin +// summary — the renditions and the pins are never read, not read and dropped +// — while every other caller asks for fullDetail. +// +// pin_summary is messaging state shown with one chat (see pin.go): read only +// under fullDetail for a viewer whose reading is not denied, and then set on +// every chat in the set, the zero summary (count 0, version 0, no latest) for +// one that has never had a pin. Every chat's summary is read in one batch, +// then every latest pinned message in one read under the reading, so a +// redacted viewer gets it redacted; the two are sequential, one goroutine +// beside the other reads. A latest pinned message the read does not return +// fails the hydration, as every pin the service shows carries its message +// (see pinsToProto); a deleted one is returned as its tombstone, which is not +// missing. // // holdings_summary is the group's holdings snapshot (see holdings.go), read // for every group in the set gated on a currency (see GatedMint), one batched @@ -270,6 +287,9 @@ func (h *Hydrator) hydrate(ctx context.Context, viewerID *commonpb.UserId, stand var holdingsChatIDs []*commonpb.ChatId gatedMints := make(map[string]*commonpb.PublicKey) hydratedMembers := make([][]*commonpb.UserId, len(chats)) + // The pin summary is messaging state (see above), and the same for every + // chat in the set, since the detail and the reading are. + showPins := detail == fullDetail && reading != ReadingDenied for i, c := range chats { if mint, ok := GatedMint(c); ok { holdingsChatIDs = append(holdingsChatIDs, c.ID) @@ -358,6 +378,8 @@ func (h *Hydrator) hydrate(ctx context.Context, viewerID *commonpb.UserId, stand viewerStates map[string]ViewerState lobbyEntries map[string]LobbyEntry holdings map[string]GroupHoldings + pinSummaries map[string]PinSummary + pinnedMessages map[MessageKey]*messagingpb.Message ) chatIDs := make([]*commonpb.ChatId, len(chats)) for i, c := range chats { @@ -426,6 +448,24 @@ func (h *Hydrator) hydrate(ctx context.Context, viewerID *commonpb.UserId, stand return err }) } + if showPins && len(chatIDs) > 0 { + // The latest pinned messages are named by the summaries, so the two + // reads are one after the other, together beside the rest. + g.Go(func() (err error) { + pinSummaries, err = h.chats.GetPinSummaries(gctx, chatIDs) + if err != nil { + return err + } + var refs []MessageRef + for _, chatID := range chatIDs { + if latest := pinSummaries[string(chatID.Value)].Latest; latest != nil { + refs = append(refs, MessageRef{ChatID: chatID, MessageID: latest.MessageID}) + } + } + pinnedMessages, err = readPinnedMessages(gctx, h.messaging, reading, nil, refs) + return err + }) + } if err := g.Wait(); err != nil { return nil, err } @@ -461,6 +501,13 @@ func (h *Hydrator) hydrate(ctx context.Context, viewerID *commonpb.UserId, stand md.LastMessage = redacted } md.LatestEventSequence = latestEventSeqs[key] + if showPins { + summary, err := pinSummaryToProto(pinnedMessages, c.ID, pinSummaries[key]) + if err != nil { + return nil, fmt.Errorf("pin summary of chat %s: %w", base64.StdEncoding.EncodeToString(c.ID.Value), err) + } + md.PinSummary = summary + } if peer, ok := dmPeerByChat[key]; ok { md.IsHidden = blockedPeers[string(peer.Value)] } diff --git a/chat/memory/store.go b/chat/memory/store.go index 598fc53..495791c 100644 --- a/chat/memory/store.go +++ b/chat/memory/store.go @@ -88,9 +88,21 @@ type memory struct { // so this one keeps it. inviteCopies map[string]*chatpb.InviteKeyEnvelope + // pins holds each chat's pins, keyed by chat ID, from its first pin on + // (see chat.Store.PinMessage). A chat whose pins were all removed keeps + // its entry, and so its version. + pins map[string]*chatPins + exclusions chat.FeedExclusions } +// chatPins is one chat's pins, keyed by message ID, and their version: the +// number of real pins and unpins the chat has seen. +type chatPins struct { + byMessage map[uint64]chat.Pin + version uint64 +} + // memberRecord is one group membership record, as the persistent stores keep // it: the joined state, when the member most recently joined (meaningful // while joined), and the roster version of the transition that last moved the @@ -121,6 +133,7 @@ func NewInMemory(excludedFromFeed []*commonpb.UserId) chat.Store { holdingsQueue: make(map[string]chat.HoldingsRefresh), invites: make(map[string]chat.Invite), inviteCopies: make(map[string]*chatpb.InviteKeyEnvelope), + pins: make(map[string]*chatPins), } } @@ -142,6 +155,7 @@ func (m *memory) reset() { m.holdingsQueue = make(map[string]chat.HoldingsRefresh) m.invites = make(map[string]chat.Invite) m.inviteCopies = make(map[string]*chatpb.InviteKeyEnvelope) + m.pins = make(map[string]*chatPins) } // isJoinedLocked reports whether the user's record on the group has them @@ -1581,6 +1595,129 @@ func cloneFeaturedGroups(f chat.FeaturedGroups) chat.FeaturedGroups { return out } +func (m *memory) PinMessage(_ context.Context, chatID *commonpb.ChatId, messageID *messagingpb.MessageId, pinnedBy *commonpb.UserId, pinnedAt time.Time, maxPins int) (chat.PinSummary, bool, error) { + pin := chat.Pin{MessageID: messageID, PinnedBy: pinnedBy, PinnedAt: pinnedAt} + if err := chat.ValidatePin(chatID, pin); err != nil { + return chat.PinSummary{}, false, err + } + pin.PinnedAt = chat.PinTime(pinnedAt) + if maxPins < 1 { + return chat.PinSummary{}, false, fmt.Errorf("max pins must be at least one") + } + + m.Lock() + defer m.Unlock() + + chatKey := string(chatID.Value) + // The no-op first, then the cap, as the persistent store judges them. + if pins := m.pins[chatKey]; pins != nil { + if _, ok := pins.byMessage[messageID.Value]; ok { + return pins.summary(), false, nil + } + if len(pins.byMessage) >= maxPins { + return chat.PinSummary{}, false, chat.ErrTooManyPins + } + } + + pins := m.pins[chatKey] + if pins == nil { + pins = &chatPins{byMessage: make(map[uint64]chat.Pin)} + m.pins[chatKey] = pins + } + pins.byMessage[messageID.Value] = pin.Clone() + pins.version++ + return pins.summary(), true, nil +} + +func (m *memory) UnpinMessage(_ context.Context, chatID *commonpb.ChatId, messageID *messagingpb.MessageId) (chat.PinSummary, bool, error) { + if err := chat.ValidatePinChatIDs(chatID); err != nil { + return chat.PinSummary{}, false, err + } + if messageID == nil { + return chat.PinSummary{}, false, fmt.Errorf("message id is required") + } + + m.Lock() + defer m.Unlock() + + pins := m.pins[string(chatID.Value)] + if pins == nil { + return chat.PinSummary{}, false, nil + } + if _, ok := pins.byMessage[messageID.Value]; !ok { + return pins.summary(), false, nil + } + delete(pins.byMessage, messageID.Value) + pins.version++ + return pins.summary(), true, nil +} + +func (m *memory) GetPinSummaries(_ context.Context, chatIDs []*commonpb.ChatId) (map[string]chat.PinSummary, error) { + if err := chat.ValidatePinChatIDs(chatIDs...); err != nil { + return nil, err + } + + m.Lock() + defer m.Unlock() + + out := make(map[string]chat.PinSummary) + for _, chatID := range chatIDs { + if pins := m.pins[string(chatID.Value)]; pins != nil { + out[string(chatID.Value)] = pins.summary() + } + } + return out, nil +} + +func (m *memory) GetPinsPage(_ context.Context, chatID *commonpb.ChatId, after *chat.PinPosition, limit int) ([]chat.Pin, error) { + if err := chat.ValidatePinChatIDs(chatID); err != nil { + return nil, err + } + if after != nil { + if err := chat.ValidatePinPosition(*after); err != nil { + return nil, err + } + } + + m.Lock() + defer m.Unlock() + + page := make([]chat.Pin, 0) + if pins := m.pins[string(chatID.Value)]; pins != nil { + for _, pin := range pins.sorted() { + if after != nil && pin.Position().Compare(*after) <= 0 { + continue + } + page = append(page, pin) + } + } + if limit > 0 && len(page) > limit { + page = page[:limit] + } + return page, nil +} + +// sorted returns copies of the chat's pins in pin order (see +// chat.PinPosition). +func (p *chatPins) sorted() []chat.Pin { + out := make([]chat.Pin, 0, len(p.byMessage)) + for _, pin := range p.byMessage { + out = append(out, pin.Clone()) + } + slices.SortFunc(out, func(a, b chat.Pin) int { return a.Position().Compare(b.Position()) }) + return out +} + +// summary returns a copy of the chat's pin summary: its latest pin in pin +// order, its count and its version. +func (p *chatPins) summary() chat.PinSummary { + s := chat.PinSummary{Count: uint32(len(p.byMessage)), Version: p.version} + if sorted := p.sorted(); len(sorted) > 0 { + s.Latest = &sorted[0] + } + return s +} + func (m *memory) PutGroupHoldings(_ context.Context, h chat.GroupHoldings) (bool, error) { if err := chat.ValidateGroupHoldings(h); err != nil { return false, err diff --git a/chat/model.go b/chat/model.go index a423397..89d755c 100644 --- a/chat/model.go +++ b/chat/model.go @@ -446,7 +446,9 @@ func (c *Chat) IsCreator(userID *commonpb.UserId) bool { // Public and private groups alike: a private group's creator moderates it on // the same footing as a public group's. Only a private group's creator, while // a member, may invite (see Invite): no one else, since anyone who may invite -// can hand joiners a chat key, and a public group needs no invite. +// can hand joiners a chat key, and a public group needs no invite. Either +// member of a DM may pin its messages, and in a group only the creator while +// a member, as for an edit (see pin.go). func (c *Chat) PermissionsFor(userID *commonpb.UserId, isMember bool) Permissions { isCreatorMember := isMember && c.IsCreator(userID) return Permissions{ @@ -456,6 +458,7 @@ func (c *Chat) PermissionsFor(userID *commonpb.UserId, isMember bool) Permission CanBan: isCreatorMember, }, CanInvite: isCreatorMember && c.IsPrivate, + CanPin: isCreatorMember || (isMember && !IsGroupChatID(c.ID)), } } @@ -1195,6 +1198,7 @@ type Permissions struct { CanEdit bool Moderation ModerationPermissions CanInvite bool + CanPin bool } // ModerationPermissions is how a user may moderate a chat's users (see @@ -1216,7 +1220,7 @@ func (m ModerationPermissions) Any() bool { // ToProto projects the permissions onto a chatpb.ViewerState_Permissions, // leaving moderation unset when it grants nothing, as the proto describes. func (p Permissions) ToProto() *chatpb.ViewerState_Permissions { - out := &chatpb.ViewerState_Permissions{CanEdit: p.CanEdit, CanInvite: p.CanInvite} + out := &chatpb.ViewerState_Permissions{CanEdit: p.CanEdit, CanInvite: p.CanInvite, CanPin: p.CanPin} if p.Moderation.Any() { out.Moderation = &chatpb.ViewerState_ModerationPermissions{ CanSilence: p.Moderation.CanSilence, diff --git a/chat/model_test.go b/chat/model_test.go index 3efed23..ca441b0 100644 --- a/chat/model_test.go +++ b/chat/model_test.go @@ -170,11 +170,18 @@ func TestPermissionsFor(t *testing.T) { group := &Chat{ID: MustGenerateGroupChatID(), Type: chatpb.ChatType_GROUP, CreatorID: creatorID} dm := &Chat{ID: MustDeriveDmChatID(chatpb.ChatType_DM, creatorID, memberID), Type: chatpb.ChatType_DM} - all := Permissions{CanEdit: true, Moderation: ModerationPermissions{CanSilence: true, CanBan: true}} + all := Permissions{CanEdit: true, Moderation: ModerationPermissions{CanSilence: true, CanBan: true}, CanPin: true} require.Equal(t, all, group.PermissionsFor(creatorID, true)) require.Equal(t, Permissions{}, group.PermissionsFor(creatorID, false)) require.Equal(t, Permissions{}, group.PermissionsFor(memberID, true)) - require.Equal(t, Permissions{}, dm.PermissionsFor(creatorID, true)) + + // Either member of a DM may pin, and do nothing else; a non-member may + // not even pin. + require.Equal(t, Permissions{CanPin: true}, dm.PermissionsFor(creatorID, true)) + require.Equal(t, Permissions{CanPin: true}, dm.PermissionsFor(memberID, true)) + require.Equal(t, Permissions{}, dm.PermissionsFor(creatorID, false)) + require.True(t, dm.PermissionsFor(memberID, true).ToProto().CanPin) + require.False(t, group.PermissionsFor(memberID, true).ToProto().CanPin) // A private group's creator, while a member, may also invite, and no one // else may; a public group's creator has nothing to invite to. diff --git a/chat/pin.go b/chat/pin.go new file mode 100644 index 0000000..b10bfab --- /dev/null +++ b/chat/pin.go @@ -0,0 +1,724 @@ +package chat + +import ( + "bytes" + "cmp" + "context" + "encoding/binary" + "errors" + "fmt" + "math" + "time" + + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + chatpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/chat/v1" + commonpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/common/v1" + eventpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/event/v1" + messagingpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/messaging/v1" + + "github.com/code-payments/flipcash2-server/model" +) + +// A chat's pins are messages in it that a member has pinned (see +// Chat.PinMessage), shown with the chat as its PinSummary and paged whole +// with GetPinnedMessages. They belong to the chat, a DM or a group alike, and +// are recorded against the IDs alone: the store neither knows nor checks +// that the chat exists, that the message is in it and may be pinned, or that +// whoever pins may; those are the server's gates. +// +// A chat's pins are ordered most recently pinned first (see PinPosition), +// and a pin keeps its place: pinning a message already pinned is a no-op +// that writes nothing and leaves it where it is, with the time and pinner +// of the pin that made it, and unpinning a message that is not pinned is a +// no-op too. Unpinning then pinning a message again is a fresh pin, at the +// top of the order with the new time and pinner. A chat holds at most a +// capped number of pins (MaxPinnedMessages), which the server names and the +// store enforces in the write that would pass it (see Store.PinMessage), so +// pins racing at the cap never leave a chat over it. +// +// The chat's pins carry one version (see PinSummary), state rather than a +// delta, as a roster's: it moves by exactly one with each real pin and +// unpin, never on a no-op, and is zero for a chat that has never had a pin. +// Editing or deleting a pinned message does not move it; the change rides +// the chat's event log like any other. Every read is strongly consistent, so +// a caller that pins and then reads the chat's pins sees its pin. + +// MaxPinnedMessages is the most messages one chat may have pinned, the cap +// the server passes to Store.PinMessage. It is the cap for now and may be +// raised; clients are told not to hardcode it (see Chat.PinMessage). +const MaxPinnedMessages = 5 + +// Pin is one pinned message in a chat as stored: which message, who pinned +// it and when. It is what Pin is projected from, with the message itself +// read by the caller. +type Pin struct { + MessageID *messagingpb.MessageId + PinnedBy *commonpb.UserId + PinnedAt time.Time +} + +// Clone deep-copies the pin, so a store's copy and its callers' share no +// memory. +func (p Pin) Clone() Pin { + clone := Pin{PinnedAt: p.PinnedAt} + if p.MessageID != nil { + clone.MessageID = &messagingpb.MessageId{Value: p.MessageID.Value} + } + if p.PinnedBy != nil { + clone.PinnedBy = &commonpb.UserId{Value: append([]byte(nil), p.PinnedBy.Value...)} + } + return clone +} + +// Position is the pin's place in its chat's order (see PinPosition). +func (p Pin) Position() PinPosition { + return PinPosition{PinnedAt: p.PinnedAt, MessageID: p.MessageID} +} + +// ToProto projects the pin onto a chatpb.Pin carrying message, the pinned +// message as the viewer may read it, which the caller reads and supplies. +func (p Pin) ToProto(message *messagingpb.Message) *chatpb.Pin { + clone := p.Clone() + return &chatpb.Pin{ + MessageId: clone.MessageID, + PinnedBy: clone.PinnedBy, + PinnedAt: timestamppb.New(p.PinnedAt), + Message: message, + } +} + +// PinPosition is a place in a chat's order of pins (see Store.GetPinsPage): +// most recently pinned first, ties broken by message ID descending, so the +// order is total and a page can resume strictly after the last pin it +// carried. A message is pinned at most once at a time, so the message ID +// alone names a pin; ties in pin time are nanosecond coincidences within one +// chat, and the tie-break exists for totality rather than for anything a +// client sees. +type PinPosition struct { + PinnedAt time.Time + MessageID *messagingpb.MessageId +} + +// Compare orders positions in pin order: negative when p comes before o +// (pinned later, or at the same instant with the greater message ID), +// positive when after, zero when they name the same place. +func (p PinPosition) Compare(o PinPosition) int { + if c := o.PinnedAt.Compare(p.PinnedAt); c != 0 { + return c + } + return cmp.Compare(o.MessageID.GetValue(), p.MessageID.GetValue()) +} + +// PinSummary is the state of a chat's pins as stored: the most recently +// pinned (Latest, nil when nothing is pinned), how many are pinned, and the +// version of the chat's pins (see the rules above). A chat that has never +// had a pin is the zero summary. +type PinSummary struct { + Latest *Pin + Count uint32 + Version uint64 +} + +// Clone deep-copies the summary. +func (s PinSummary) Clone() PinSummary { + clone := PinSummary{Count: s.Count, Version: s.Version} + if s.Latest != nil { + latest := s.Latest.Clone() + clone.Latest = &latest + } + return clone +} + +// ToProto projects the summary onto a chatpb.PinSummary, with latestMessage, +// which the caller reads and supplies, as the message of its latest pin. The +// latest pin is omitted when there is none, and latestMessage is then +// ignored. +func (s PinSummary) ToProto(latestMessage *messagingpb.Message) *chatpb.PinSummary { + out := &chatpb.PinSummary{Count: s.Count, Version: s.Version} + if s.Latest != nil { + out.Latest = s.Latest.ToProto(latestMessage) + } + return out +} + +// PinTime is t as a store records a pin's time: at nanosecond precision, in +// UTC, with no monotonic reading, which is what a read gives back. +func PinTime(t time.Time) time.Time { + return time.Unix(0, t.UnixNano()).UTC() +} + +// maxPinTime is the latest pin time a store can record, the last instant +// whose Unix nanoseconds fit an int64. +var maxPinTime = time.Unix(0, math.MaxInt64) + +// ValidatePin returns an error unless a store accepts pin as a pin in +// chatID: chatID a DM or a group chat ID (either; the store does not care +// which), a message ID, a pinner that is a user ID, and a pin time at or +// after the Unix epoch that Unix nanoseconds can hold. +func ValidatePin(chatID *commonpb.ChatId, pin Pin) error { + if err := validatePinChatID(chatID); err != nil { + return err + } + switch { + case pin.MessageID == nil: + return fmt.Errorf("pin: message id is required") + case len(pin.PinnedBy.GetValue()) != model.UserIDSize: + return fmt.Errorf("pin: pinned by must be a user id") + case pin.PinnedAt.Before(time.Unix(0, 0)): + return fmt.Errorf("pin: pinned before the unix epoch") + case pin.PinnedAt.After(maxPinTime): + return fmt.Errorf("pin: pinned after the last representable instant") + } + return nil +} + +// ValidatePinPosition returns an error unless pos is a position a store +// can resume a page of pins after: a message ID and a pin time a pin could +// hold (see ValidatePin). +func ValidatePinPosition(pos PinPosition) error { + switch { + case pos.MessageID == nil: + return fmt.Errorf("pin position: message id is required") + case pos.PinnedAt.Before(time.Unix(0, 0)): + return fmt.Errorf("pin position: pinned before the unix epoch") + case pos.PinnedAt.After(maxPinTime): + return fmt.Errorf("pin position: pinned after the last representable instant") + } + return nil +} + +// validatePinChatID returns an error unless chatID is a DM or a group chat +// ID, the two families whose chats hold pins. +func validatePinChatID(chatID *commonpb.ChatId) error { + if n := len(chatID.GetValue()); n != DmChatIDSize && n != GroupChatIDSize { + return fmt.Errorf("pin: chat id %x is neither a dm nor a group chat id", chatID.GetValue()) + } + return nil +} + +// ValidatePinChatIDs returns an error unless every ID is a DM or a group chat +// ID (see ValidatePin), for the reads and the unpin, which take no Pin. +func ValidatePinChatIDs(chatIDs ...*commonpb.ChatId) error { + for _, chatID := range chatIDs { + if err := validatePinChatID(chatID); err != nil { + return err + } + } + return nil +} + +// Pinning, the RPCs: PinMessage and UnpinMessage change a chat's pins, and +// GetPinnedMessages pages them. +// +// Who may pin is ViewerState.Permissions.can_pin (see Chat.PermissionsFor): +// either member of a DM, and a group's recorded creator while a member, +// decided as an edit is, so a departed creator pins nothing until they +// rejoin and a legacy group with no recorded creator is pinned by no one. +// Pinning and unpinning share one gate (see pinnableChat): the record +// (NOT_FOUND), then can_pin as Chat.PermissionsFor decides it, DENIED +// otherwise: first as if the caller were a member, so one who could not pin +// even then (in a group, anyone but the creator today) costs no membership +// read, then with membership read (see Access.IsMemberWithChat: a DM's off +// its record, a group's strongly consistent). An ID that names no chat, a +// malformed one included, is NOT_FOUND off the record read, as GetChat +// answers it. +// +// PinMessage then reads the message, in full, since the caller is a member: +// one absent from the read is MESSAGE_NOT_FOUND, and one of a kind that may +// not be pinned (see pinnableMessage: a deleted message, a system message, or +// any kind this server does not know) MESSAGE_NOT_ALLOWED, both judged after +// the gate so that no one who may not pin can probe which message IDs a chat +// holds. The read is eventually consistent, so a message sent a moment ago +// may briefly be refused, which a retry outlives. UnpinMessage reads no +// message: unpinning one that is not pinned, or that never existed, is the +// OK no-op, as the proto describes. The cap on a chat's pins is the server's +// (maxPinnedMessages, MaxPinnedMessages in production) and enforced by the +// store in the write (see Store.PinMessage), so a pin past it is +// TOO_MANY_PINS however pins race. +// +// GetPinnedMessages is gated as messaging's GetMessages is: the record +// (NOT_FOUND), then the caller's standing under view_mode (see +// ListenerStanding.Reading), DENIED when it allows no reading, and the +// messages are read and redacted under that reading. The chat's summary is +// read first and the page after it, each a strongly consistent read of its +// own (see Store.GetPinsPage), so the page is never older than the summary's +// version: every change up to it is in the page, and one landing between the +// reads may be too, which a client applying the changes after that version +// takes as the no-op it is (a pin of a message already shown, an unpin of +// one already gone). The token binds the page to the chat (see +// encodePinToken). +// +// Every Pin the service returns or publishes carries its message, which the +// proto requires: the messages of every pin a call shows, the summary's +// latest among them, are read in one batch (see pinsToProto, and hydrate for +// the summary a chat's metadata carries), and a pinned message missing from +// it fails the call with Internal rather than show a pin without it. A +// message deleted after it was pinned is not missing: its tombstone is shown +// like any message, and clients hide such a pin (see Pin.message). Nothing +// unpins it on a delete: it stays pinned, counting toward the cap, until +// someone permitted to pin unpins it. +// +// A real pin or unpin, never a no-op, is published once as a +// MetadataUpdate.PinsChanged carrying the change and the summary after it, +// to every member, the caller's own devices included (see publishPinsChanged). +// It is built from the very protos the response carries, so when those +// cannot be built the call fails as above and nothing is published: the +// write has landed, so a retry is the no-op and publishes nothing either, +// and the chat's members converge from the next read of its pins. Publishing +// is best-effort, as everywhere in the service. + +const ( + // maxGetPinnedMessagesPageSize bounds a single GetPinnedMessages page + // and is its default. + maxGetPinnedMessagesPageSize = 100 + + pinTokenVersion = 1 + + // pinTokenLen is the fixed size of a GetPinnedMessages paging token: the + // version byte, the chat ID padded to a DM's size, so that a DM's and a + // group's are bound alike, the pin time and the message ID (see + // encodePinToken). + pinTokenLen = 1 + DmChatIDSize + 8 + 8 +) + +// errPinnedMessageMissing is pinsToProto's refusal of a pin whose message the +// read did not return (see above). +var errPinnedMessageMissing = errors.New("pinned message missing") + +// PinMessage pins a message in a chat (see above). +func (s *Server) PinMessage(ctx context.Context, req *chatpb.PinMessageRequest) (*chatpb.PinMessageResponse, error) { + userID, err := s.authz.Authorize(ctx, req, &req.Auth) + if err != nil { + return nil, err + } + + log := s.pinLog(userID, req.ChatId, req.MessageId) + + c, result, err := s.pinnableChat(ctx, log, req.ChatId, userID) + if err != nil { + return nil, err + } + switch result { + case pinNotFound: + return &chatpb.PinMessageResponse{Result: chatpb.PinMessageResponse_NOT_FOUND}, nil + case pinDenied: + return &chatpb.PinMessageResponse{Result: chatpb.PinMessageResponse_DENIED}, nil + } + + // The caller is a member, so the message is read in full, and the same + // read serves the response and the event, which only members receive. + known, err := s.messaging.Messages(ctx, []MessageRef{{ChatID: c.ID, MessageID: req.MessageId}}, ReadingFull) + if err != nil { + log.With(zap.Error(err)).Warn("Failure reading message to pin") + return nil, status.Error(codes.Internal, "") + } + message, ok := known[MessageKeyOf(c.ID, req.MessageId)] + if !ok { + return &chatpb.PinMessageResponse{Result: chatpb.PinMessageResponse_MESSAGE_NOT_FOUND}, nil + } + if !pinnableMessage(message) { + return &chatpb.PinMessageResponse{Result: chatpb.PinMessageResponse_MESSAGE_NOT_ALLOWED}, nil + } + + pin := Pin{MessageID: req.MessageId, PinnedBy: userID, PinnedAt: PinTime(time.Now())} + summary, changed, err := s.chats.PinMessage(ctx, c.ID, pin.MessageID, pin.PinnedBy, pin.PinnedAt, s.maxPinnedMessages) + switch { + case errors.Is(err, ErrTooManyPins): + return &chatpb.PinMessageResponse{Result: chatpb.PinMessageResponse_TOO_MANY_PINS}, nil + case err != nil: + log.With(zap.Error(err)).Warn("Failure pinning message") + return nil, status.Error(codes.Internal, "") + } + + // A real pin is the one this call made, which the event carries; a no-op + // made none, and shows the summary alone. + var pins []Pin + if changed { + pins = []Pin{pin} + } + pinned, summaryProto, err := s.pinsToProto(ctx, c.ID, ReadingFull, known, pins, summary) + if err != nil { + log.With(zap.Error(err)).Warn("Failure reading pinned messages") + return nil, status.Error(codes.Internal, "") + } + if changed { + s.publishPinsChanged(c, &chatpb.MetadataUpdate_PinsChanged{ + Change: &chatpb.MetadataUpdate_PinsChanged_Pinned{Pinned: pinned[0]}, + Summary: summaryProto, + }) + } + + return &chatpb.PinMessageResponse{ + Result: chatpb.PinMessageResponse_OK, + PinSummary: summaryProto, + }, nil +} + +// UnpinMessage unpins a message in a chat, whoever pinned it (see above). +func (s *Server) UnpinMessage(ctx context.Context, req *chatpb.UnpinMessageRequest) (*chatpb.UnpinMessageResponse, error) { + userID, err := s.authz.Authorize(ctx, req, &req.Auth) + if err != nil { + return nil, err + } + + log := s.pinLog(userID, req.ChatId, req.MessageId) + + c, result, err := s.pinnableChat(ctx, log, req.ChatId, userID) + if err != nil { + return nil, err + } + switch result { + case pinNotFound: + return &chatpb.UnpinMessageResponse{Result: chatpb.UnpinMessageResponse_NOT_FOUND}, nil + case pinDenied: + return &chatpb.UnpinMessageResponse{Result: chatpb.UnpinMessageResponse_DENIED}, nil + } + + summary, changed, err := s.chats.UnpinMessage(ctx, c.ID, req.MessageId) + if err != nil { + log.With(zap.Error(err)).Warn("Failure unpinning message") + return nil, status.Error(codes.Internal, "") + } + + _, summaryProto, err := s.pinsToProto(ctx, c.ID, ReadingFull, nil, nil, summary) + if err != nil { + log.With(zap.Error(err)).Warn("Failure reading pinned messages") + return nil, status.Error(codes.Internal, "") + } + if changed { + s.publishPinsChanged(c, &chatpb.MetadataUpdate_PinsChanged{ + Change: &chatpb.MetadataUpdate_PinsChanged_Unpinned{Unpinned: &messagingpb.MessageId{Value: req.MessageId.Value}}, + Summary: summaryProto, + }) + } + + return &chatpb.UnpinMessageResponse{ + Result: chatpb.UnpinMessageResponse_OK, + PinSummary: summaryProto, + }, nil +} + +// GetPinnedMessages pages a chat's pins, most recently pinned first, each with +// its message as the caller may read it (see above). +func (s *Server) GetPinnedMessages(ctx context.Context, req *chatpb.GetPinnedMessagesRequest) (*chatpb.GetPinnedMessagesResponse, error) { + userID, err := s.authz.Authorize(ctx, req, &req.Auth) + if err != nil { + return nil, err + } + + log := s.log.With( + zap.String("user_id", model.UserIDString(userID)), + zap.String("chat_id", model.ChatIDString(req.ChatId)), + ) + + limit := maxGetPinnedMessagesPageSize + if pageSize := req.GetQueryOptions().GetPageSize(); pageSize > 0 && int(pageSize) < limit { + limit = int(pageSize) + } + + var after *PinPosition + if token := req.GetQueryOptions().GetPagingToken(); token != nil { + pos, ok := decodePinToken(token, req.ChatId) + if !ok { + return nil, status.Error(codes.InvalidArgument, "invalid paging token") + } + after = &pos + } + + c, err := s.chats.GetChatByID(ctx, req.ChatId) + switch { + case errors.Is(err, ErrChatNotFound): + return &chatpb.GetPinnedMessagesResponse{Result: chatpb.GetPinnedMessagesResponse_NOT_FOUND}, nil + case err != nil: + log.With(zap.Error(err)).Warn("Failure getting chat") + return nil, status.Error(codes.Internal, "") + } + + standing, err := s.access.ListenerStandingWithChat(ctx, c, userID, req.GetViewMode()) + if err != nil { + log.With(zap.Error(err)).Warn("Failure determining chat standing") + return nil, status.Error(codes.Internal, "") + } + reading := standing.Reading(req.GetViewMode()) + if reading == ReadingDenied { + return &chatpb.GetPinnedMessagesResponse{Result: chatpb.GetPinnedMessagesResponse_DENIED}, nil + } + + // The summary before the page, so the page is never older than the + // summary's version (see above). + summaries, err := s.chats.GetPinSummaries(ctx, []*commonpb.ChatId{c.ID}) + if err != nil { + log.With(zap.Error(err)).Warn("Failure reading pin summary") + return nil, status.Error(codes.Internal, "") + } + // One more than the page, for an exact has_more. + page, err := s.chats.GetPinsPage(ctx, c.ID, after, limit+1) + if err != nil { + log.With(zap.Error(err)).Warn("Failure reading pins") + return nil, status.Error(codes.Internal, "") + } + + hasMore := len(page) > limit + if hasMore { + page = page[:limit] + } + + pins, summary, err := s.pinsToProto(ctx, c.ID, reading, nil, page, summaries[string(c.ID.Value)]) + if err != nil { + log.With(zap.Error(err)).Warn("Failure reading pinned messages") + return nil, status.Error(codes.Internal, "") + } + + resp := &chatpb.GetPinnedMessagesResponse{ + Result: chatpb.GetPinnedMessagesResponse_OK, + Pins: pins, + PinSummary: summary, + HasMore: hasMore, + } + if hasMore { + resp.PagingToken = encodePinToken(c.ID, page[len(page)-1].Position()) + } + return resp, nil +} + +// pinResult is the shared gate's verdict on a pin or unpin's chat. +type pinResult uint8 + +const ( + pinOK pinResult = iota + pinNotFound + pinDenied +) + +// pinnableChat is the gate PinMessage and UnpinMessage start with (see +// above): the chat's record exists (NOT_FOUND), and the caller may pin in it, +// can_pin as Chat.PermissionsFor decides it (DENIED otherwise): asked first +// as if the caller were a member, so one who could not pin even then costs +// no membership read, and then with membership read. +// On OK the record is returned. A gRPC error is returned only for a failed +// read. +func (s *Server) pinnableChat(ctx context.Context, log *zap.Logger, chatID *commonpb.ChatId, userID *commonpb.UserId) (*Chat, pinResult, error) { + c, err := s.chats.GetChatByID(ctx, chatID) + switch { + case errors.Is(err, ErrChatNotFound): + return nil, pinNotFound, nil + case err != nil: + log.With(zap.Error(err)).Warn("Failure getting chat") + return nil, 0, status.Error(codes.Internal, "") + } + // Membership only ever grants, so a caller who could not pin even as a + // member is refused before membership is read. + if !c.PermissionsFor(userID, true).CanPin { + return nil, pinDenied, nil + } + isMember, err := s.access.IsMemberWithChat(ctx, c, userID) + if err != nil { + log.With(zap.Error(err)).Warn("Failure checking chat membership") + return nil, 0, status.Error(codes.Internal, "") + } + if !c.PermissionsFor(userID, isMember).CanPin { + return nil, pinDenied, nil + } + return c, pinOK, nil +} + +// pinsToProto projects pins, and summary, onto their protos, each pin +// carrying its message as a read under reading returns it: known holds +// messages of chatID already read under reading, keyed by MessageKeyOf, and +// every other message the pins and the summary's latest name is read in one +// batch (see readPinnedMessages). A message that neither holds fails the +// projection with errPinnedMessageMissing (see above); a tombstone is a +// message like any other. +func (s *Server) pinsToProto(ctx context.Context, chatID *commonpb.ChatId, reading Reading, known map[MessageKey]*messagingpb.Message, pins []Pin, summary PinSummary) ([]*chatpb.Pin, *chatpb.PinSummary, error) { + refs := make([]MessageRef, 0, len(pins)+1) + for _, p := range pins { + refs = append(refs, MessageRef{ChatID: chatID, MessageID: p.MessageID}) + } + if summary.Latest != nil { + refs = append(refs, MessageRef{ChatID: chatID, MessageID: summary.Latest.MessageID}) + } + messages, err := readPinnedMessages(ctx, s.messaging, reading, known, refs) + if err != nil { + return nil, nil, err + } + + out := make([]*chatpb.Pin, len(pins)) + for i, p := range pins { + m, err := pinnedMessage(messages, chatID, p) + if err != nil { + return nil, nil, err + } + out[i] = p.ToProto(m) + } + summaryProto, err := pinSummaryToProto(messages, chatID, summary) + if err != nil { + return nil, nil, err + } + return out, summaryProto, nil +} + +// readPinnedMessages returns the pinned messages refs name, across any number +// of chats, keyed by MessageKeyOf, as a read under reading returns them: +// those known holds (already read under reading) as they are, and the rest +// in one batch from reader, each read once however often it is named. A ref +// whose message the read did not return maps to nil, which pinnedMessage +// refuses. +func readPinnedMessages(ctx context.Context, reader MessagingReader, reading Reading, known map[MessageKey]*messagingpb.Message, refs []MessageRef) (map[MessageKey]*messagingpb.Message, error) { + messages := make(map[MessageKey]*messagingpb.Message, len(refs)) + var toRead []MessageRef + for _, ref := range refs { + key := MessageKeyOf(ref.ChatID, ref.MessageID) + if _, ok := messages[key]; ok { + continue + } + if m, ok := known[key]; ok { + messages[key] = m + continue + } + // Marked as asked for, so a message named twice is read once. + messages[key] = nil + toRead = append(toRead, ref) + } + if len(toRead) > 0 { + read, err := reader.Messages(ctx, toRead, reading) + if err != nil { + return nil, err + } + for _, ref := range toRead { + key := MessageKeyOf(ref.ChatID, ref.MessageID) + messages[key] = read[key] + } + } + return messages, nil +} + +// pinnedMessage returns the message of chatID's pin p from messages (see +// readPinnedMessages), or errPinnedMessageMissing when it holds none: every +// Pin the service shows carries its message (see above). +func pinnedMessage(messages map[MessageKey]*messagingpb.Message, chatID *commonpb.ChatId, p Pin) (*messagingpb.Message, error) { + m := messages[MessageKeyOf(chatID, p.MessageID)] + if m == nil { + return nil, fmt.Errorf("%w: message %d", errPinnedMessageMissing, p.MessageID.GetValue()) + } + return m, nil +} + +// pinSummaryToProto projects chatID's summary onto its proto, its latest pin +// carrying its message from messages (see readPinnedMessages), failing with +// errPinnedMessageMissing as pinnedMessage does. A summary with no latest pin +// needs no message. +func pinSummaryToProto(messages map[MessageKey]*messagingpb.Message, chatID *commonpb.ChatId, summary PinSummary) (*chatpb.PinSummary, error) { + var latest *messagingpb.Message + if summary.Latest != nil { + m, err := pinnedMessage(messages, chatID, *summary.Latest) + if err != nil { + return nil, err + } + latest = m + } + return summary.ToProto(latest), nil +} + +// publishPinsChanged announces a real change to c's pins to its members (see +// above), as one ChatUpdate carrying change: a group's once on its chat +// topic, excluding no one, so the caller's other devices hear it too; a DM's +// on each member's user topic, as every DM update is, except the Flipcash +// team account's, which is never delivered to (see messaging.NewSender). +// Best-effort and non-blocking, like every publish here. +func (s *Server) publishPinsChanged(c *Chat, change *chatpb.MetadataUpdate_PinsChanged) { + e := &eventpb.Event{ + Id: model.MustGenerateEventID(), + Ts: timestamppb.Now(), + Type: &eventpb.Event_ChatUpdate{ChatUpdate: &eventpb.ChatUpdate{ + Chat: c.ID, + MetadataUpdates: []*chatpb.MetadataUpdate{{ + Kind: &chatpb.MetadataUpdate_PinsChanged_{PinsChanged: change}, + }}, + }}, + } + if IsGroupChatID(c.ID) { + s.chatEventBus.OnEvent(c.ID, &eventpb.ChatEvent{ChatId: c.ID, Event: e}) + return + } + team := s.rules.teamUserID + for _, m := range c.Members { + if team != nil && bytes.Equal(m.Value, team.Value) { + continue + } + s.userEventBus.OnEvent(m, e) + } +} + +// pinnableMessage reports whether m is of a kind that may be pinned: it has +// content, and every piece of it is a kind on the allowlist. A deleted +// message's tombstone (DeletedContent) and a system message (SystemContent) +// are not, and nor is any kind this server does not know, so a kind the proto +// gains is refused until it is added here. +func pinnableMessage(m *messagingpb.Message) bool { + if len(m.Content) == 0 { + return false + } + for _, c := range m.Content { + switch c.Type.(type) { + case *messagingpb.Content_Text, + *messagingpb.Content_Cash, + *messagingpb.Content_Reply, + *messagingpb.Content_Media, + *messagingpb.Content_Encrypted, + *messagingpb.Content_Widget: + default: + return false + } + } + return true +} + +// pinLog is the logger a pin or unpin logs with: the caller, the chat and the +// message. +func (s *Server) pinLog(userID *commonpb.UserId, chatID *commonpb.ChatId, messageID *messagingpb.MessageId) *zap.Logger { + return s.log.With( + zap.String("user_id", model.UserIDString(userID)), + zap.String("chat_id", model.ChatIDString(chatID)), + zap.Uint64("message_id", messageID.GetValue()), + ) +} + +// encodePinToken serializes a GetPinnedMessages page's resume position, +// bound to the chat it pages: the version byte, the chat ID zero-padded to +// DmChatIDSize, the pin time as big-endian Unix nanoseconds, and the message +// ID, big-endian. +func encodePinToken(chatID *commonpb.ChatId, pos PinPosition) *commonpb.PagingToken { + buf := make([]byte, pinTokenLen) + buf[0] = pinTokenVersion + copy(buf[1:1+DmChatIDSize], chatID.Value) + binary.BigEndian.PutUint64(buf[1+DmChatIDSize:], uint64(pos.PinnedAt.UnixNano())) + binary.BigEndian.PutUint64(buf[1+DmChatIDSize+8:], pos.MessageID.GetValue()) + return &commonpb.PagingToken{Value: buf} +} + +// decodePinToken parses a GetPinnedMessages token minted for chatID; a +// malformed token, one whose pin time no pin could hold, or one bound to +// another chat, is refused. +func decodePinToken(token *commonpb.PagingToken, chatID *commonpb.ChatId) (PinPosition, bool) { + b := token.GetValue() + if len(b) != pinTokenLen || b[0] != pinTokenVersion { + return PinPosition{}, false + } + bound := make([]byte, DmChatIDSize) + copy(bound, chatID.GetValue()) + if !bytes.Equal(b[1:1+DmChatIDSize], bound) { + return PinPosition{}, false + } + nanos := binary.BigEndian.Uint64(b[1+DmChatIDSize:]) + if nanos > math.MaxInt64 { + return PinPosition{}, false + } + return PinPosition{ + PinnedAt: time.Unix(0, int64(nanos)).UTC(), + MessageID: &messagingpb.MessageId{Value: binary.BigEndian.Uint64(b[1+DmChatIDSize+8:])}, + }, true +} diff --git a/chat/pin_test.go b/chat/pin_test.go new file mode 100644 index 0000000..62c98ad --- /dev/null +++ b/chat/pin_test.go @@ -0,0 +1,81 @@ +package chat + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + chatpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/chat/v1" + messagingpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/messaging/v1" + + "github.com/code-payments/flipcash2-server/model" +) + +func TestPin_ToProto(t *testing.T) { + pin := Pin{ + MessageID: &messagingpb.MessageId{Value: 42}, + PinnedBy: model.MustGenerateUserID(), + PinnedAt: time.Unix(1_700_000_000, 123_456_789).UTC(), + } + message := &messagingpb.Message{ + MessageId: &messagingpb.MessageId{Value: 42}, + SenderId: model.MustGenerateUserID(), + Content: []*messagingpb.Content{{Type: &messagingpb.Content_Text{Text: &messagingpb.TextContent{Text: "hi"}}}}, + Ts: timestamppb.Now(), + EventSequence: 1, + } + + got := pin.ToProto(message) + require.NoError(t, got.Validate()) + require.Equal(t, uint64(42), got.MessageId.Value) + require.Equal(t, pin.PinnedBy.Value, got.PinnedBy.Value) + require.True(t, got.PinnedAt.AsTime().Equal(pin.PinnedAt)) + require.Same(t, message, got.Message) + + // The projection shares no memory with the pin. + got.MessageId.Value = 7 + got.PinnedBy.Value[0] ^= 0xff + require.Equal(t, uint64(42), pin.MessageID.Value) + require.NotEqual(t, pin.PinnedBy.Value[0], got.PinnedBy.Value[0]) +} + +func TestPinSummary_ToProto(t *testing.T) { + // No pin: latest omitted, and the message ignored. + empty := PinSummary{Version: 3}.ToProto(&messagingpb.Message{}) + require.True(t, proto.Equal(&chatpb.PinSummary{Version: 3}, empty)) + require.True(t, proto.Equal(&chatpb.PinSummary{}, PinSummary{}.ToProto(nil))) + + pin := Pin{ + MessageID: &messagingpb.MessageId{Value: 9}, + PinnedBy: model.MustGenerateUserID(), + PinnedAt: time.Unix(1_700_000_000, 1).UTC(), + } + message := &messagingpb.Message{MessageId: &messagingpb.MessageId{Value: 9}} + got := PinSummary{Latest: &pin, Count: 2, Version: 5}.ToProto(message) + require.True(t, proto.Equal(&chatpb.PinSummary{Latest: pin.ToProto(message), Count: 2, Version: 5}, got)) + require.Same(t, message, got.Latest.Message) +} + +func TestPinPosition_Compare(t *testing.T) { + at := func(s int64) time.Time { return time.Unix(1_700_000_000+s, 0).UTC() } + id := func(v uint64) *messagingpb.MessageId { return &messagingpb.MessageId{Value: v} } + + // Later first, then the greater message ID first. + require.Negative(t, PinPosition{PinnedAt: at(2), MessageID: id(1)}.Compare(PinPosition{PinnedAt: at(1), MessageID: id(9)})) + require.Positive(t, PinPosition{PinnedAt: at(1), MessageID: id(9)}.Compare(PinPosition{PinnedAt: at(2), MessageID: id(1)})) + require.Negative(t, PinPosition{PinnedAt: at(1), MessageID: id(9)}.Compare(PinPosition{PinnedAt: at(1), MessageID: id(8)})) + require.Positive(t, PinPosition{PinnedAt: at(1), MessageID: id(8)}.Compare(PinPosition{PinnedAt: at(1), MessageID: id(9)})) + require.Zero(t, PinPosition{PinnedAt: at(1), MessageID: id(8)}.Compare(PinPosition{PinnedAt: at(1), MessageID: id(8)})) +} + +func TestPinTime(t *testing.T) { + local := time.Unix(1_700_000_000, 123_456_789).In(time.FixedZone("x", 3600)) + require.Equal(t, time.Unix(1_700_000_000, 123_456_789).UTC(), PinTime(local)) + + now := time.Now() + require.True(t, PinTime(now).Equal(now)) + require.Equal(t, time.UTC, PinTime(now).Location()) +} diff --git a/chat/server.go b/chat/server.go index 482b3bc..5d14d15 100644 --- a/chat/server.go +++ b/chat/server.go @@ -30,6 +30,18 @@ type MessageRef struct { MessageID *messagingpb.MessageId } +// MessageKey is a message's identity across chats as a map key: its chat's +// ID, as string(chatID.Value), and its ID within the chat. +type MessageKey struct { + ChatID string + MessageID uint64 +} + +// MessageKeyOf returns the key of a chat's message. +func MessageKeyOf(chatID *commonpb.ChatId, messageID *messagingpb.MessageId) MessageKey { + return MessageKey{ChatID: string(chatID.GetValue()), MessageID: messageID.GetValue()} +} + // PointerRef names a chat and the members whose pointers to hydrate. The feed // builds one ref per chat — a DM's members, or the viewer alone in a group // (see hydrate) — to batch the pointer lookup across the page. @@ -47,6 +59,17 @@ type MessagingReader interface { // string(chatID.Value). Refs without a message are absent from the map. LastMessages(ctx context.Context, refs []MessageRef) (map[string]*messagingpb.Message, error) + // Messages returns the message for each ref that exists, across any + // number of chats and any number per chat, keyed by MessageKeyOf, as a + // read under reading returns it: media resolved and, for a redacted + // reading, every message redacted, exactly as the Messaging service's own + // reads (the caller has decided the reading from the viewer's standing). + // Refs without a message are absent from the map; a deleted message is + // returned as its tombstone. The read is eventually consistent, so a + // message sent a moment ago may be absent. It returns an error when a + // message cannot be redacted. + Messages(ctx context.Context, refs []MessageRef, reading Reading) (map[MessageKey]*messagingpb.Message, error) + // Pointers returns the delivered/read pointers for the members named in each // ref, keyed by string(chatID.Value). Chats with no matching pointers are // absent from the map. @@ -268,6 +291,11 @@ type Server struct { // WithMaxActiveInvites to exercise the refusal cheaply. maxActiveInvites int + // maxPinnedMessages caps the messages one chat may have pinned (see + // pin.go). MaxPinnedMessages in production; tests lower it with + // WithMaxPinnedMessages to exercise the refusal cheaply. + maxPinnedMessages int + chatpb.UnimplementedChatServer } @@ -290,6 +318,14 @@ func WithMaxActiveInvites(n int) ServerOption { } } +// WithMaxPinnedMessages overrides MaxPinnedMessages, the most messages one +// chat may have pinned (see Store.PinMessage). n must be at least one. +func WithMaxPinnedMessages(n int) ServerOption { + return func(s *Server) { + s.maxPinnedMessages = n + } +} + func NewServer( log *zap.Logger, @@ -344,6 +380,7 @@ func NewServer( rosterWholeReadCap: rosterWholeReadCap, lobbyLimits: DefaultLobbyLimits, maxActiveInvites: MaxActiveInvites, + maxPinnedMessages: MaxPinnedMessages, } for _, opt := range opts { opt(s) diff --git a/chat/store.go b/chat/store.go index 77ed3f2..4146b83 100644 --- a/chat/store.go +++ b/chat/store.go @@ -79,6 +79,11 @@ var ( // ErrTooManyInvites indicates that a private group holds as many ACTIVE // invites as allowed (see MaxActiveInvites). ErrTooManyInvites = errors.New("too many invites") + + // ErrTooManyPins indicates that a chat has as many pinned messages as + // the cap the pin was made under allows (see Store.PinMessage and + // MaxPinnedMessages). + ErrTooManyPins = errors.New("too many pins") ) // MaxGroupChatCreationMembers is the largest initial member set a group chat @@ -181,6 +186,13 @@ type DmFeedCursor struct { // UseCount, which only grows, and RevokedAt, which is set once, so a write // conditioned on the fields it read is its own compare-and-set, and a // version would be needed only if an invite could ever be edited. +// +// A chat's pins (see Pin) are one record per pinned message, kept with the +// chat's PinSummary, whose count and version move in the same write as the +// pin (see PinMessage). Like the rest, they are written against the IDs +// alone, a DM's or a group's: that the chat exists, that the message is in +// it and not deleted, and that the caller may pin there are the caller's +// gates. type Store interface { // PutChat persists a new chat and its membership. It returns ErrChatExists // if a chat with the same ID already exists, ErrNoMembers if the member set @@ -885,6 +897,55 @@ type Store interface { // zero. GetFeaturedGroups(ctx context.Context, userID *commonpb.UserId) (FeaturedGroups, error) + // PinMessage pins messageID in chatID, by pinnedBy as of pinnedAt, and + // returns the chat's PinSummary after the call. A message already pinned + // is a no-op that writes nothing and returns the summary as it stands + // with changed false: the pin keeps its time, its pinner and its place + // in the order, and the version does not move. Otherwise a chat with + // maxPins or more pins is refused with ErrTooManyPins, judged against + // the count as of the write, with nothing written, so pins racing at the + // cap never leave the chat over it; the no-op is judged first, so a + // retried pin is answered at the cap like anywhere else. A real pin is + // one write that records it, makes it the summary's Latest, and moves + // the count up by one and the version by exactly one. + // + // pinnedAt is recorded at nanosecond precision in UTC (see PinTime). It + // returns an error if the arguments fail ValidatePin or maxPins is less + // than one. It does not check that the chat exists, that the message is + // in it, or who may pin: those are the caller's. + PinMessage(ctx context.Context, chatID *commonpb.ChatId, messageID *messagingpb.MessageId, pinnedBy *commonpb.UserId, pinnedAt time.Time, maxPins int) (summary PinSummary, changed bool, err error) + + // UnpinMessage unpins messageID in chatID, whoever pinned it, and + // returns the chat's PinSummary after the call. A message not pinned is + // a no-op that writes nothing and returns the summary as it stands with + // changed false. A real unpin is one write that removes the pin, moves + // the count down by one and the version up by exactly one, and, when the + // pin was the Latest, makes the next pin in order (see PinPosition) the + // Latest, or leaves none when it was the last. It returns an error if + // chatID is neither a DM nor a group chat ID or messageID is nil. + UnpinMessage(ctx context.Context, chatID *commonpb.ChatId, messageID *messagingpb.MessageId) (summary PinSummary, changed bool, err error) + + // GetPinSummaries returns the PinSummary of each given chat that has ever + // had a pin, keyed by string(chatID.Value), its version above zero even + // when nothing is pinned now; a chat that never had one is absent, and + // reads as the zero summary. Duplicate IDs collapse. The read is strongly + // consistent: it reflects every pin and unpin that completed before it, + // so a caller that pins and then reads sees its pin. It returns an error + // if any ID is neither a DM nor a group chat ID, and an empty result (no + // error) when chatIDs is empty. + GetPinSummaries(ctx context.Context, chatIDs []*commonpb.ChatId) (map[string]PinSummary, error) + + // GetPinsPage returns up to limit of chatID's pins in pin order (see + // PinPosition: most recently pinned first), strictly after the given + // position, or from the start when it is nil. A limit of zero or less + // means no limit. The read is strongly consistent, like + // GetPinSummaries, but each call is a read of its own: a pin made or + // removed between two pages is seen or missed as the proto describes + // (see Chat.GetPinnedMessages). A chat with no pins, or that does not + // exist, is an empty result. It returns an error if chatID is neither a + // DM nor a group chat ID, or after fails ValidatePinPosition. + GetPinsPage(ctx context.Context, chatID *commonpb.ChatId, after *PinPosition, limit int) ([]Pin, error) + // PutGroupHoldings records h as its group's holdings snapshot (see // GroupHoldings), replacing the one stored, and reports whether it did. // Every snapshot is stored, listed or not, but only a listed one (see diff --git a/chat/tests/server.go b/chat/tests/server.go index a98a51a..892de08 100644 --- a/chat/tests/server.go +++ b/chat/tests/server.go @@ -47,6 +47,7 @@ import ( "github.com/code-payments/flipcash2-server/profile" "github.com/code-payments/flipcash2-server/protoutil" "github.com/code-payments/flipcash2-server/push" + "github.com/code-payments/flipcash2-server/redact" "github.com/code-payments/flipcash2-server/testutil" ) @@ -202,6 +203,18 @@ func RunServerTests(t *testing.T, s chat.Store, teardown func()) { testServer_InviteJoin_Refused, testServer_InviteJoin_PublicGroup, testServer_InviteJoin_Creator, + testServer_Pin_Gates, + testServer_Pin_MessageNotFound, + testServer_Pin_ContentKinds, + testServer_Pin_Cap, + testServer_Pin_Group, + testServer_Pin_Dm, + testServer_GetPinnedMessages_Paging, + testServer_GetPinnedMessages_Reading, + testServer_Pin_MissingMessage, + testServer_PinSummary_Hydration, + testServer_PinSummary_Reading, + testServer_PinSummary_MissingMessage, } { tf(t, s) teardown() @@ -245,6 +258,9 @@ type serverConfig struct { // maxActiveInvites, when set, overrides chat.MaxActiveInvites. maxActiveInvites int + + // maxPinnedMessages, when set, overrides chat.MaxPinnedMessages. + maxPinnedMessages int } func newServerEnv(t *testing.T, s chat.Store) *serverEnv { @@ -287,6 +303,9 @@ func newServerEnvWithConfig(t *testing.T, s chat.Store, cfg serverConfig) *serve if cfg.maxActiveInvites > 0 { opts = append(opts, chat.WithMaxActiveInvites(cfg.maxActiveInvites)) } + if cfg.maxPinnedMessages > 0 { + opts = append(opts, chat.WithMaxPinnedMessages(cfg.maxPinnedMessages)) + } hydrator := chat.NewHydrator(log, blocklist, s, media, messaging, profiles, access.Rules(), teamUserID) server := chat.NewServer(log, authz, accounts, blocklist, s, media, messaging, moderator, profiles, systemMessages, pusher, access, hydrator, userBus, chatBus, cfg.disablePublicGroupRoster, opts...) cc := testutil.RunGRPCServer(t, log, testutil.WithService(func(s *grpc.Server) { @@ -523,9 +542,11 @@ func (f *fakeMedia) ResolveRenditions(_ context.Context, ids []*blobpb.BlobId) ( // fakeMessagingReader is a canned chat.MessagingReader for server tests: it // returns whatever last messages, pointers, and head event sequences a test -// registers per chat. +// registers per chat, and whatever messages it registers per message (see +// Messages). type fakeMessagingReader struct { lastMessages map[string]*messagingpb.Message + messages map[chat.MessageKey]*messagingpb.Message pointers map[string][]*messagingpb.Pointer latestEventSeqs map[string]uint64 pointerLookups map[string]int @@ -538,6 +559,7 @@ type fakeMessagingReader struct { func newFakeMessagingReader() *fakeMessagingReader { return &fakeMessagingReader{ lastMessages: make(map[string]*messagingpb.Message), + messages: make(map[chat.MessageKey]*messagingpb.Message), pointers: make(map[string][]*messagingpb.Pointer), latestEventSeqs: make(map[string]uint64), pointerLookups: make(map[string]int), @@ -559,6 +581,29 @@ func (f *fakeMessagingReader) LastMessages(ctx context.Context, refs []chat.Mess return out, nil } +// Messages returns the registered message for each ref, redacted under a +// redacted reading as the real reader does (see redact.Message); refs with +// none registered are absent. +func (f *fakeMessagingReader) Messages(_ context.Context, refs []chat.MessageRef, reading chat.Reading) (map[chat.MessageKey]*messagingpb.Message, error) { + out := make(map[chat.MessageKey]*messagingpb.Message) + for _, ref := range refs { + key := chat.MessageKeyOf(ref.ChatID, ref.MessageID) + m, ok := f.messages[key] + if !ok { + continue + } + if reading == chat.ReadingRedacted { + redacted, err := redact.Message(ref.ChatID, m) + if err != nil { + return nil, err + } + m = redacted + } + out[key] = m + } + return out, nil +} + func (f *fakeMessagingReader) Pointers(_ context.Context, refs []chat.PointerRef) (map[string][]*messagingpb.Pointer, error) { out := make(map[string][]*messagingpb.Pointer) for _, ref := range refs { @@ -4773,13 +4818,13 @@ func muteForever() *chatpb.MuteState { return &chatpb.MuteState{Duration: &chatpb.MuteState_Forever_{Forever: &chatpb.MuteState_Forever{}}} } -// viewerState is the ViewerState every carrier projects: the mute under -// settings (nil for none), the permissions, and the version. isCreator is -// whether the viewer is the group's creator while a member, which grants -// can_edit and both moderation permissions together (see -// chat.Chat.PermissionsFor). +// viewerState is the ViewerState every carrier projects in a group: the +// mute under settings (nil for none), the permissions, and the version. +// isCreator is whether the viewer is the group's creator while a member, +// which grants can_edit, can_pin and both moderation permissions together +// (see chat.Chat.PermissionsFor). func viewerState(version uint64, mute *chatpb.MuteState, isCreator bool) *chatpb.ViewerState { - permissions := &chatpb.ViewerState_Permissions{CanEdit: isCreator} + permissions := &chatpb.ViewerState_Permissions{CanEdit: isCreator, CanPin: isCreator} if isCreator { permissions.Moderation = &chatpb.ViewerState_ModerationPermissions{CanSilence: true, CanBan: true} } @@ -4790,10 +4835,24 @@ func viewerState(version uint64, mute *chatpb.MuteState, isCreator bool) *chatpb } } -// memberViewerState is what a member who has never written a record, and -// may not edit the chat, is shown: the zero record with no permissions. +// memberViewerState is what a group member who has never written a record, +// and may not edit the chat, is shown: the zero record with no permissions. var memberViewerState = viewerState(0, nil, false) +// dmViewerState is the ViewerState a DM's member is shown: the mute under +// settings (nil for none) and the version, with can_pin alone among the +// permissions, since either member of a DM may pin and no one edits or +// moderates one (see chat.Chat.PermissionsFor). +func dmViewerState(version uint64, mute *chatpb.MuteState) *chatpb.ViewerState { + state := viewerState(version, mute, false) + state.Permissions.CanPin = true + return state +} + +// dmMemberViewerState is what a DM's member who has never written a record +// is shown (see dmViewerState). +var dmMemberViewerState = dmViewerState(0, nil) + // viewerStateUpdatesOnUserTopic returns every ViewerStateChanged for chatID // published on userID's topic, in publish order. func (e *serverEnv) viewerStateUpdatesOnUserTopic(userID *commonpb.UserId, chatID *commonpb.ChatId) []*chatpb.ViewerState { @@ -4838,10 +4897,10 @@ func testServer_MuteChat_Lifecycle(t *testing.T, s chat.Store) { chatID := e.putDMWithPeer(chatpb.ChatType_CONTACT_DM, peer, at(1)) // A chat the viewer has never touched carries the zero state for a member: - // no mute, no permissions on a DM, version zero. + // no mute, can_pin alone among the permissions on a DM, version zero. resp := e.getChat(e.keys, chatID) require.Equal(t, chatpb.GetChatResponse_OK, resp.Result) - require.NoError(t, protoutil.ProtoEqualError(memberViewerState, resp.Metadata.ViewerState)) + require.NoError(t, protoutil.ProtoEqualError(dmMemberViewerState, resp.Metadata.ViewerState)) // A timed mute, recorded at second precision, is a first transition. The // end is chosen on a whole second so the sub-second variant below lands @@ -4849,7 +4908,7 @@ func testServer_MuteChat_Lifecycle(t *testing.T, s chat.Store) { until := time.Now().Add(time.Hour).Truncate(time.Second) muted := e.mustMuteChat(e.keys, chatID, muteUntil(until)) require.Equal(t, chatpb.MuteChatResponse_OK, muted.Result) - want := viewerState(1, muteUntil(until), false) + want := dmViewerState(1, muteUntil(until)) require.NoError(t, protoutil.ProtoEqualError(want, muted.ViewerState)) // The same state is what a read hydrates onto the chat... @@ -4870,7 +4929,7 @@ func testServer_MuteChat_Lifecycle(t *testing.T, s chat.Store) { // Indefinite where timed was is a real change. muted = e.mustMuteChat(e.keys, chatID, muteForever()) require.Equal(t, chatpb.MuteChatResponse_OK, muted.Result) - want = viewerState(2, muteForever(), false) + want = dmViewerState(2, muteForever()) require.NoError(t, protoutil.ProtoEqualError(want, muted.ViewerState)) states = e.waitForViewerStateUpdates(e.userID, chatID, 2) require.NoError(t, protoutil.ProtoEqualError(want, states[1])) @@ -4878,7 +4937,7 @@ func testServer_MuteChat_Lifecycle(t *testing.T, s chat.Store) { // A clear keeps the record and its version, with no mute under settings. unmuted := e.mustUnmuteChat(e.keys, chatID) require.Equal(t, chatpb.UnmuteChatResponse_OK, unmuted.Result) - want = viewerState(3, nil, false) + want = dmViewerState(3, nil) require.NoError(t, protoutil.ProtoEqualError(want, unmuted.ViewerState)) resp = e.getChat(e.keys, chatID) require.NoError(t, protoutil.ProtoEqualError(want, resp.Metadata.ViewerState)) @@ -5014,7 +5073,7 @@ func testServer_MuteChat_Gates(t *testing.T, s chat.Store) { require.Equal(t, codes.InvalidArgument, status.Code(err), "mute %v", mute) } got := e.getChat(e.keys, dm) - require.NoError(t, protoutil.ProtoEqualError(memberViewerState, got.Metadata.ViewerState)) + require.NoError(t, protoutil.ProtoEqualError(dmMemberViewerState, got.Metadata.ViewerState)) } func testServer_LeaveChat_ClearsMute(t *testing.T, s chat.Store) { @@ -5074,13 +5133,15 @@ func testServer_GetChat_ViewerState_LapsedMuteReturned(t *testing.T, s chat.Stor chatID := e.putDM(at(1)) - until := time.Now().Add(time.Second).Truncate(time.Second) + // Whole seconds, as a mute is stored, and at least a second away, so the + // mute still ends in the future when the request arrives. + until := time.Now().Truncate(time.Second).Add(2 * time.Second) muted := e.mustMuteChat(e.keys, chatID, muteUntil(until)) require.Equal(t, chatpb.MuteChatResponse_OK, muted.Result) - want := viewerState(1, muteUntil(until), false) + want := dmViewerState(1, muteUntil(until)) require.NoError(t, protoutil.ProtoEqualError(want, muted.ViewerState)) - time.Sleep(1500 * time.Millisecond) + time.Sleep(time.Until(until) + 500*time.Millisecond) resp := e.getChat(e.keys, chatID) require.Equal(t, chatpb.GetChatResponse_OK, resp.Result) @@ -5088,7 +5149,7 @@ func testServer_GetChat_ViewerState_LapsedMuteReturned(t *testing.T, s chat.Stor // Lapsed is still recorded: clearing it is a real transition. unmuted := e.mustUnmuteChat(e.keys, chatID) - require.NoError(t, protoutil.ProtoEqualError(viewerState(2, nil, false), unmuted.ViewerState)) + require.NoError(t, protoutil.ProtoEqualError(dmViewerState(2, nil), unmuted.ViewerState)) } func testServer_GetDmChatFeed_ViewerState(t *testing.T, s chat.Store) { @@ -5105,9 +5166,9 @@ func testServer_GetDmChatFeed_ViewerState(t *testing.T, s chat.Store) { for _, md := range resp.Chats { byID[string(md.ChatId.Value)] = md } - want := viewerState(1, muteForever(), false) + want := dmViewerState(1, muteForever()) require.NoError(t, protoutil.ProtoEqualError(want, byID[string(mutedDM.Value)].ViewerState)) - require.NoError(t, protoutil.ProtoEqualError(memberViewerState, byID[string(quietDM.Value)].ViewerState)) + require.NoError(t, protoutil.ProtoEqualError(dmMemberViewerState, byID[string(quietDM.Value)].ViewerState)) } // putOwnedGroup persists a group the env user created and is a member of, @@ -6329,13 +6390,14 @@ func testServer_ViewerState_Permissions(t *testing.T, s chat.Store) { canEdit := viewerState(0, nil, true) - // GetChat: the creator may edit their group and nothing else; another - // member and a DM participant may do nothing; a stranger gets no state. + // GetChat: the creator may edit, moderate and pin in their group and + // nothing else; another member may do nothing; a DM participant may pin + // alone; a stranger gets no state. require.NoError(t, protoutil.ProtoEqualError(canEdit, e.getChat(e.keys, owned).Metadata.ViewerState)) require.NoError(t, protoutil.ProtoEqualError(memberViewerState, e.getChat(e.keys, plain).Metadata.ViewerState)) - require.NoError(t, protoutil.ProtoEqualError(memberViewerState, e.getChat(e.keys, dm).Metadata.ViewerState)) + require.NoError(t, protoutil.ProtoEqualError(dmMemberViewerState, e.getChat(e.keys, dm).Metadata.ViewerState)) require.NoError(t, protoutil.ProtoEqualError(memberViewerState, e.getChat(memberKeys, owned).Metadata.ViewerState)) - require.NoError(t, protoutil.ProtoEqualError(memberViewerState, e.getChat(memberKeys, dm).Metadata.ViewerState)) + require.NoError(t, protoutil.ProtoEqualError(dmMemberViewerState, e.getChat(memberKeys, dm).Metadata.ViewerState)) require.Nil(t, e.getChat(strangerKeys, owned).Metadata.ViewerState) // The feeds carry the same. @@ -6350,7 +6412,7 @@ func testServer_ViewerState_Permissions(t *testing.T, s chat.Store) { } dmFeed := e.getDmFeed(&commonpb.QueryOptions{}) require.Len(t, dmFeed.Chats, 1) - require.NoError(t, protoutil.ProtoEqualError(memberViewerState, dmFeed.Chats[0].ViewerState)) + require.NoError(t, protoutil.ProtoEqualError(dmMemberViewerState, dmFeed.Chats[0].ViewerState)) // A mute carries the permissions with it, on the response and the update. muted := e.mustMuteChat(e.keys, owned, muteForever()) @@ -9047,3 +9109,773 @@ func testServer_InviteJoin_Creator(t *testing.T, s chat.Store) { } e.requireInviteUseCount(invite.InviteId, 0) } + +func (e *serverEnv) pinMessage(keys model.KeyPair, chatID *commonpb.ChatId, messageID uint64) *chatpb.PinMessageResponse { + req := &chatpb.PinMessageRequest{ChatId: chatID, MessageId: &messagingpb.MessageId{Value: messageID}} + require.NoError(e.t, keys.Auth(req, &req.Auth)) + resp, err := e.client.PinMessage(e.ctx, req) + require.NoError(e.t, err) + return resp +} + +func (e *serverEnv) unpinMessage(keys model.KeyPair, chatID *commonpb.ChatId, messageID uint64) *chatpb.UnpinMessageResponse { + resp, err := e.tryUnpinMessage(keys, chatID, messageID) + require.NoError(e.t, err) + return resp +} + +func (e *serverEnv) tryUnpinMessage(keys model.KeyPair, chatID *commonpb.ChatId, messageID uint64) (*chatpb.UnpinMessageResponse, error) { + req := &chatpb.UnpinMessageRequest{ChatId: chatID, MessageId: &messagingpb.MessageId{Value: messageID}} + require.NoError(e.t, keys.Auth(req, &req.Auth)) + return e.client.UnpinMessage(e.ctx, req) +} + +func (e *serverEnv) getPinnedMessages(keys model.KeyPair, chatID *commonpb.ChatId, mode messagingpb.ViewMode, opts *commonpb.QueryOptions) (*chatpb.GetPinnedMessagesResponse, error) { + req := &chatpb.GetPinnedMessagesRequest{ChatId: chatID, ViewMode: mode, QueryOptions: opts} + require.NoError(e.t, keys.Auth(req, &req.Auth)) + return e.client.GetPinnedMessages(e.ctx, req) +} + +func (e *serverEnv) mustGetPinnedMessages(keys model.KeyPair, chatID *commonpb.ChatId, mode messagingpb.ViewMode, opts *commonpb.QueryOptions) *chatpb.GetPinnedMessagesResponse { + resp, err := e.getPinnedMessages(keys, chatID, mode, opts) + require.NoError(e.t, err) + return resp +} + +// putMessage registers a text message in chatID with the fake messaging +// reader, as the Messages read returns it, and returns it. +func (e *serverEnv) putMessage(chatID *commonpb.ChatId, id uint64, sender *commonpb.UserId, text string) *messagingpb.Message { + m := textMessage(id, sender, text) + e.messaging.messages[chat.MessageKeyOf(chatID, m.MessageId)] = m + return m +} + +// putContentMessage registers a message carrying content in chatID with the +// fake messaging reader, as the Messages read returns it, and returns it. +func (e *serverEnv) putContentMessage(chatID *commonpb.ChatId, id uint64, sender *commonpb.UserId, content ...*messagingpb.Content) *messagingpb.Message { + m := &messagingpb.Message{ + MessageId: &messagingpb.MessageId{Value: id}, + SenderId: sender, + Content: content, + Ts: timestamppb.New(at(int64(id))), + EventSequence: id, + } + e.messaging.messages[chat.MessageKeyOf(chatID, m.MessageId)] = m + return m +} + +// deleteMessage replaces a registered message with its tombstone, as the +// Messages read returns a deleted message. +func (e *serverEnv) deleteMessage(chatID *commonpb.ChatId, id uint64) { + key := chat.MessageKeyOf(chatID, &messagingpb.MessageId{Value: id}) + m := e.messaging.messages[key] + require.NotNil(e.t, m) + e.messaging.messages[key] = &messagingpb.Message{ + MessageId: m.MessageId, + SenderId: m.SenderId, + Content: []*messagingpb.Content{{Type: &messagingpb.Content_Deleted{ + Deleted: &messagingpb.DeletedContent{DeletedTs: timestamppb.Now()}, + }}}, + Ts: m.Ts, + EventSequence: m.EventSequence, + } +} + +// pinsChangedOnChatTopic returns every PinsChanged published on chatID's +// topic, paired with the users each publish excluded, in publish order. +func (e *serverEnv) pinsChangedOnChatTopic(chatID *commonpb.ChatId) (changes []*chatpb.MetadataUpdate_PinsChanged, excludes [][]*commonpb.UserId) { + for _, ev := range e.chatObserver.GetEvents(func(k *commonpb.ChatId) bool { return bytes.Equal(k.Value, chatID.Value) }) { + require.Equal(e.t, chatID.Value, ev.Event.Event.GetChatUpdate().GetChat().GetValue()) + for _, md := range ev.Event.Event.GetChatUpdate().GetMetadataUpdates() { + if change := md.GetPinsChanged(); change != nil { + changes = append(changes, change) + excludes = append(excludes, ev.Event.ExcludeUserIds) + } + } + } + return changes, excludes +} + +// pinsChangedOnUserTopic returns every PinsChanged for chatID published on +// userID's topic, in publish order. +func (e *serverEnv) pinsChangedOnUserTopic(userID *commonpb.UserId, chatID *commonpb.ChatId) []*chatpb.MetadataUpdate_PinsChanged { + var changes []*chatpb.MetadataUpdate_PinsChanged + for _, ev := range e.userObserver.GetEvents(func(k *commonpb.UserId) bool { return bytes.Equal(k.Value, userID.Value) }) { + update := ev.Event.GetChatUpdate() + if !bytes.Equal(update.GetChat().GetValue(), chatID.Value) { + continue + } + for _, md := range update.GetMetadataUpdates() { + if change := md.GetPinsChanged(); change != nil { + changes = append(changes, change) + } + } + } + return changes +} + +// waitForGroupPinsChanged waits for n PinsChanged on chatID's topic, gives +// the bus a moment to deliver any more, and returns exactly n, none of them +// excluding anyone, with none for the group on any user topic. +func (e *serverEnv) waitForGroupPinsChanged(chatID *commonpb.ChatId, n int, users ...*commonpb.UserId) []*chatpb.MetadataUpdate_PinsChanged { + if n > 0 { + e.chatObserver.WaitFor(e.t, func([]*event.KeyAndEvent[*commonpb.ChatId, *eventpb.ChatEvent]) bool { + changes, _ := e.pinsChangedOnChatTopic(chatID) + return len(changes) >= n + }) + } + time.Sleep(100 * time.Millisecond) + changes, excludes := e.pinsChangedOnChatTopic(chatID) + require.Len(e.t, changes, n) + for _, excluded := range excludes { + require.Empty(e.t, excluded) + } + for _, userID := range users { + require.Empty(e.t, e.pinsChangedOnUserTopic(userID, chatID)) + } + return changes +} + +// waitForDmPinsChanged waits for n PinsChanged for chatID on userID's topic, +// gives the bus a moment to deliver any more, and returns exactly n, with +// none on the chat's topic. +func (e *serverEnv) waitForDmPinsChanged(userID *commonpb.UserId, chatID *commonpb.ChatId, n int) []*chatpb.MetadataUpdate_PinsChanged { + if n > 0 { + e.userObserver.WaitFor(e.t, func([]*event.KeyAndEvent[*commonpb.UserId, *eventpb.Event]) bool { + return len(e.pinsChangedOnUserTopic(userID, chatID)) >= n + }) + } + time.Sleep(100 * time.Millisecond) + changes := e.pinsChangedOnUserTopic(userID, chatID) + require.Len(e.t, changes, n) + onChat, _ := e.pinsChangedOnChatTopic(chatID) + require.Empty(e.t, onChat) + return changes +} + +// requirePinProto asserts a pin names messageID, was made by pinnedBy, and +// carries want as its message. +func requirePinProto(t *testing.T, pin *chatpb.Pin, want *messagingpb.Message, pinnedBy *commonpb.UserId) { + t.Helper() + require.NotNil(t, pin) + require.Equal(t, want.MessageId.Value, pin.MessageId.Value) + require.Equal(t, pinnedBy.Value, pin.PinnedBy.Value) + require.NotNil(t, pin.PinnedAt) + require.NoError(t, protoutil.ProtoEqualError(want, pin.Message)) +} + +// requirePinSummaryProto asserts a summary's count and version, and that its +// latest pin carries latest as its message (nil for no latest pin). +func requirePinSummaryProto(t *testing.T, summary *chatpb.PinSummary, count uint32, version uint64, latest *messagingpb.Message, pinnedBy *commonpb.UserId) { + t.Helper() + require.NotNil(t, summary) + require.Equal(t, count, summary.Count) + require.Equal(t, version, summary.Version) + if latest == nil { + require.Nil(t, summary.Latest) + return + } + requirePinProto(t, summary.Latest, latest, pinnedBy) +} + +// testServer_Pin_Gates pins who may pin and unpin (see +// ViewerState.Permissions.can_pin): either member of a DM, a group's creator +// while a member, and no one else. +func testServer_Pin_Gates(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + peerID, peerKeys := e.addUser() + memberID, memberKeys := e.addUser() + _, strangerKeys := e.addUser() + + dm := e.putDMWithPeer(chatpb.ChatType_DM, peerID, at(1)) + e.putMessage(dm, 1, e.userID, "hi") + owned := e.putOwnedGroup("Owned", nil, memberID) + e.putMessage(owned, 1, memberID, "hello") + + // can_pin says who may. + require.True(t, e.getChat(e.keys, dm).Metadata.ViewerState.Permissions.CanPin) + require.True(t, e.getChat(peerKeys, dm).Metadata.ViewerState.Permissions.CanPin) + require.True(t, e.getChat(e.keys, owned).Metadata.ViewerState.Permissions.CanPin) + require.False(t, e.getChat(memberKeys, owned).Metadata.ViewerState.Permissions.CanPin) + + // Either member of a DM pins and unpins, whoever pinned. + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(e.keys, dm, 1).Result) + require.Equal(t, chatpb.UnpinMessageResponse_OK, e.unpinMessage(peerKeys, dm, 1).Result) + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(peerKeys, dm, 1).Result) + + // A group's creator pins; another member may not, nor unpin. + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(e.keys, owned, 1).Result) + require.Equal(t, chatpb.PinMessageResponse_DENIED, e.pinMessage(memberKeys, owned, 1).Result) + require.Equal(t, chatpb.UnpinMessageResponse_DENIED, e.unpinMessage(memberKeys, owned, 1).Result) + + // A non-member may not, a DM included, and is told so before the message + // is looked at. + require.Equal(t, chatpb.PinMessageResponse_DENIED, e.pinMessage(strangerKeys, dm, 1).Result) + require.Equal(t, chatpb.UnpinMessageResponse_DENIED, e.unpinMessage(strangerKeys, dm, 1).Result) + require.Equal(t, chatpb.PinMessageResponse_DENIED, e.pinMessage(strangerKeys, owned, 99).Result) + require.Equal(t, chatpb.UnpinMessageResponse_DENIED, e.unpinMessage(strangerKeys, owned, 1).Result) + + // A legacy group with no recorded creator is pinned by no one. + plain := e.putGroup("Plain", at(1), memberID) + e.putMessage(plain, 1, memberID, "hey") + require.Equal(t, chatpb.PinMessageResponse_DENIED, e.pinMessage(e.keys, plain, 1).Result) + require.False(t, e.getChat(e.keys, plain).Metadata.ViewerState.Permissions.CanPin) + + // A departed creator pins nothing until they rejoin. + require.Equal(t, chatpb.LeaveChatResponse_OK, e.mustLeaveChat(e.keys, owned).Result) + require.Equal(t, chatpb.PinMessageResponse_DENIED, e.pinMessage(e.keys, owned, 1).Result) + require.Equal(t, chatpb.UnpinMessageResponse_DENIED, e.unpinMessage(e.keys, owned, 1).Result) + require.Equal(t, chatpb.JoinChatResponse_OK, e.mustJoinChat(e.keys, owned).Result) + require.Equal(t, chatpb.UnpinMessageResponse_OK, e.unpinMessage(e.keys, owned, 1).Result) + + // A chat that does not exist is NOT_FOUND, a DM's ID or a group's. + for _, missing := range []*commonpb.ChatId{chat.MustGenerateGroupChatID(), generateDmChatID()} { + require.Equal(t, chatpb.PinMessageResponse_NOT_FOUND, e.pinMessage(e.keys, missing, 1).Result) + require.Equal(t, chatpb.UnpinMessageResponse_NOT_FOUND, e.unpinMessage(e.keys, missing, 1).Result) + require.Equal(t, chatpb.GetPinnedMessagesResponse_NOT_FOUND, e.mustGetPinnedMessages(e.keys, missing, messagingpb.ViewMode_FULL, nil).Result) + } +} + +// testServer_Pin_MessageNotFound pins that only a message in the chat may be +// pinned, that a deleted message, a system message or content of no known +// kind may not, and that a refusal writes and publishes nothing. +func testServer_Pin_MessageNotFound(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + memberID, _ := e.addUser() + owned := e.putOwnedGroup("Owned", nil, memberID) + other := e.putOwnedGroup("Other", nil, memberID) + e.putMessage(owned, 1, memberID, "hello") + e.putMessage(other, 2, memberID, "elsewhere") + e.putContentMessage(owned, 3, nil, &messagingpb.Content{Type: &messagingpb.Content_System{ + System: &messagingpb.SystemContent{FallbackText: "A new member joined the group"}, + }}) + e.putContentMessage(owned, 4, memberID, &messagingpb.Content{}) + e.putContentMessage(owned, 5, memberID) + e.putContentMessage(owned, 6, memberID, + &messagingpb.Content{Type: &messagingpb.Content_Text{Text: &messagingpb.TextContent{Text: "hi"}}}, + &messagingpb.Content{Type: &messagingpb.Content_System{System: &messagingpb.SystemContent{FallbackText: "x"}}}, + ) + + // Never sent, or sent in another chat. + require.Equal(t, chatpb.PinMessageResponse_MESSAGE_NOT_FOUND, e.pinMessage(e.keys, owned, 7).Result) + require.Equal(t, chatpb.PinMessageResponse_MESSAGE_NOT_FOUND, e.pinMessage(e.keys, owned, 2).Result) + + // A system message, content of no known kind, no content, or a known kind + // alongside one that is not. + for _, id := range []uint64{3, 4, 5, 6} { + require.Equal(t, chatpb.PinMessageResponse_MESSAGE_NOT_ALLOWED, e.pinMessage(e.keys, owned, id).Result, id) + } + + // Deleted. + e.deleteMessage(owned, 1) + require.Equal(t, chatpb.PinMessageResponse_MESSAGE_NOT_ALLOWED, e.pinMessage(e.keys, owned, 1).Result) + + resp := e.mustGetPinnedMessages(e.keys, owned, messagingpb.ViewMode_FULL, nil) + require.Equal(t, chatpb.GetPinnedMessagesResponse_OK, resp.Result) + require.Empty(t, resp.Pins) + requirePinSummaryProto(t, resp.PinSummary, 0, 0, nil, nil) + e.waitForGroupPinsChanged(owned, 0) +} + +// testServer_Pin_ContentKinds pins that a message of every kind on the +// allowlist may be pinned, whatever its content. +func testServer_Pin_ContentKinds(t *testing.T, s chat.Store) { + e := newServerEnvWithConfig(t, s, serverConfig{maxPinnedMessages: 10}) + peerID, _ := e.addUser() + dm := e.putDMWithPeer(chatpb.ChatType_DM, peerID, at(1)) + + text := &messagingpb.Content{Type: &messagingpb.Content_Text{Text: &messagingpb.TextContent{Text: "hi"}}} + blobID := &blobpb.BlobId{Value: bytes.Repeat([]byte{7}, 16)} + kinds := []*messagingpb.Content{ + text, + {Type: &messagingpb.Content_Cash{Cash: &messagingpb.CashContent{ + IntentId: &commonpb.IntentId{Value: bytes.Repeat([]byte{1}, 32)}, + TotalAmount: &commonpb.CryptoPaymentAmount{ + Currency: "usd", + NativeAmount: 1, + Quarks: 1_000_000, + Mint: &commonpb.PublicKey{Value: bytes.Repeat([]byte{2}, 32)}, + }, + Recipient: &messagingpb.CashContent_SingleReceiver{SingleReceiver: peerID}, + }}}, + {Type: &messagingpb.Content_Reply{Reply: &messagingpb.ReplyContent{ + RepliedMessageId: &messagingpb.MessageId{Value: 1}, + Content: []*messagingpb.Content{text}, + }}}, + {Type: &messagingpb.Content_Media{Media: &messagingpb.MediaContent{ + Items: []*blobpb.Media{{Renditions: []*blobpb.Rendition{{ + Role: blobpb.Rendition_ORIGINAL, + BlobId: blobID, + Blob: &blobpb.BlobMetadata{ + MimeType: "image/jpeg", + SizeBytes: 1024, + DownloadUrl: &blobpb.DownloadUrl{ + Url: "https://cdn.blobs.test/" + hex.EncodeToString(blobID.Value), + ExpiresAt: timestamppb.New(at(1).Add(time.Hour)), + }, + }, + }}}}, + }}}, + {Type: &messagingpb.Content_Encrypted{Encrypted: &messagingpb.EncryptedContent{ + Scheme: messagingpb.EncryptedContent_X25519_XCHACHA20POLY1305, + Nonce: bytes.Repeat([]byte{3}, 24), + Ciphertext: bytes.Repeat([]byte{4}, 32), + }}}, + {Type: &messagingpb.Content_Widget{Widget: &messagingpb.WidgetContent{ + Type: &messagingpb.WidgetContent_ShareProfile{ShareProfile: &messagingpb.ShareProfileWidget{ + Username: &commonpb.Username{Value: "someone"}, + }}, + }}}, + } + for i, c := range kinds { + id := uint64(i + 1) + e.putContentMessage(dm, id, e.userID, c) + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(e.keys, dm, id).Result, c) + } + + resp := e.mustGetPinnedMessages(e.keys, dm, messagingpb.ViewMode_FULL, nil) + require.Equal(t, chatpb.GetPinnedMessagesResponse_OK, resp.Result) + require.Len(t, resp.Pins, len(kinds)) +} + +// testServer_Pin_Cap pins the cap on a chat's pins: a pin past it is +// TOO_MANY_PINS, and unpinning one makes room. +func testServer_Pin_Cap(t *testing.T, s chat.Store) { + e := newServerEnvWithConfig(t, s, serverConfig{maxPinnedMessages: 2}) + peerID, _ := e.addUser() + dm := e.putDMWithPeer(chatpb.ChatType_DM, peerID, at(1)) + for id := uint64(1); id <= 3; id++ { + e.putMessage(dm, id, e.userID, "hi") + } + + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(e.keys, dm, 1).Result) + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(e.keys, dm, 2).Result) + require.Equal(t, chatpb.PinMessageResponse_TOO_MANY_PINS, e.pinMessage(e.keys, dm, 3).Result) + + // A message already pinned is the no-op at the cap, not a refusal. + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(e.keys, dm, 2).Result) + + require.Equal(t, chatpb.UnpinMessageResponse_OK, e.unpinMessage(e.keys, dm, 1).Result) + resp := e.pinMessage(e.keys, dm, 3) + require.Equal(t, chatpb.PinMessageResponse_OK, resp.Result) + require.Equal(t, uint32(2), resp.PinSummary.Count) + e.waitForDmPinsChanged(e.userID, dm, 4) +} + +// testServer_Pin_Group follows a group's pins through pins, unpins and their +// no-ops: each response carries the summary with its latest pin's message, +// and each real change, and nothing else, is published once on the chat +// topic, excluding no one. +func testServer_Pin_Group(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + memberID, _ := e.addUser() + owned := e.putOwnedGroup("Owned", nil, memberID) + first := e.putMessage(owned, 1, memberID, "first") + second := e.putMessage(owned, 2, e.userID, "second") + + pinned := e.pinMessage(e.keys, owned, 1) + require.Equal(t, chatpb.PinMessageResponse_OK, pinned.Result) + requirePinSummaryProto(t, pinned.PinSummary, 1, 1, first, e.userID) + changes := e.waitForGroupPinsChanged(owned, 1, e.userID, memberID) + requirePinProto(t, changes[0].GetPinned(), first, e.userID) + require.NoError(t, protoutil.ProtoEqualError(pinned.PinSummary, changes[0].Summary)) + + // Pinning it again is the no-op: same summary, nothing published. + again := e.pinMessage(e.keys, owned, 1) + require.Equal(t, chatpb.PinMessageResponse_OK, again.Result) + require.NoError(t, protoutil.ProtoEqualError(pinned.PinSummary, again.PinSummary)) + e.waitForGroupPinsChanged(owned, 1) + + pinned = e.pinMessage(e.keys, owned, 2) + require.Equal(t, chatpb.PinMessageResponse_OK, pinned.Result) + requirePinSummaryProto(t, pinned.PinSummary, 2, 2, second, e.userID) + changes = e.waitForGroupPinsChanged(owned, 2) + requirePinProto(t, changes[1].GetPinned(), second, e.userID) + require.NoError(t, protoutil.ProtoEqualError(pinned.PinSummary, changes[1].Summary)) + + // Unpinning the latest makes the one before it the latest, carrying its + // message. + unpinned := e.unpinMessage(e.keys, owned, 2) + require.Equal(t, chatpb.UnpinMessageResponse_OK, unpinned.Result) + requirePinSummaryProto(t, unpinned.PinSummary, 1, 3, first, e.userID) + changes = e.waitForGroupPinsChanged(owned, 3) + require.Equal(t, uint64(2), changes[2].GetUnpinned().GetValue()) + require.NoError(t, protoutil.ProtoEqualError(unpinned.PinSummary, changes[2].Summary)) + + // Unpinning a message that is not pinned, or was never sent, is the + // no-op, with nothing published. + for _, id := range []uint64{2, 99} { + resp := e.unpinMessage(e.keys, owned, id) + require.Equal(t, chatpb.UnpinMessageResponse_OK, resp.Result) + require.NoError(t, protoutil.ProtoEqualError(unpinned.PinSummary, resp.PinSummary)) + } + e.waitForGroupPinsChanged(owned, 3) + + // The last unpin leaves no latest. + unpinned = e.unpinMessage(e.keys, owned, 1) + require.Equal(t, chatpb.UnpinMessageResponse_OK, unpinned.Result) + requirePinSummaryProto(t, unpinned.PinSummary, 0, 4, nil, nil) + changes = e.waitForGroupPinsChanged(owned, 4) + require.Equal(t, uint64(1), changes[3].GetUnpinned().GetValue()) + requirePinSummaryProto(t, changes[3].Summary, 0, 4, nil, nil) +} + +// testServer_Pin_Dm pins a DM's events: each real change reaches both +// members on their user topics, the caller included, and never the Flipcash +// team account, which is never delivered to. +func testServer_Pin_Dm(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + peerID, peerKeys := e.addUser() + dm := e.putDMWithPeer(chatpb.ChatType_DM, peerID, at(1)) + msg := e.putMessage(dm, 1, peerID, "pin me") + + pinned := e.pinMessage(peerKeys, dm, 1) + require.Equal(t, chatpb.PinMessageResponse_OK, pinned.Result) + requirePinSummaryProto(t, pinned.PinSummary, 1, 1, msg, peerID) + for _, userID := range []*commonpb.UserId{e.userID, peerID} { + changes := e.waitForDmPinsChanged(userID, dm, 1) + requirePinProto(t, changes[0].GetPinned(), msg, peerID) + require.NoError(t, protoutil.ProtoEqualError(pinned.PinSummary, changes[0].Summary)) + } + + require.Equal(t, chatpb.UnpinMessageResponse_OK, e.unpinMessage(e.keys, dm, 1).Result) + for _, userID := range []*commonpb.UserId{e.userID, peerID} { + changes := e.waitForDmPinsChanged(userID, dm, 2) + require.Equal(t, uint64(1), changes[1].GetUnpinned().GetValue()) + } + + // In a DM with the team account the user's devices hear of it and the + // team's do not. + teamDM := e.putDMWithPeer(chatpb.ChatType_DM, e.teamUserID, at(2)) + e.putMessage(teamDM, 1, e.teamUserID, "welcome") + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(e.keys, teamDM, 1).Result) + e.waitForDmPinsChanged(e.userID, teamDM, 1) + require.Empty(t, e.pinsChangedOnUserTopic(e.teamUserID, teamDM)) +} + +// testServer_GetPinnedMessages_Paging pages a chat's pins most recently +// pinned first, each with its message, the summary on every page, and a +// token bound to the chat. +func testServer_GetPinnedMessages_Paging(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + memberID, memberKeys := e.addUser() + owned := e.putOwnedGroup("Owned", nil, memberID) + other := e.putOwnedGroup("Other", nil, memberID) + var messages []*messagingpb.Message + for id := uint64(1); id <= 5; id++ { + messages = append(messages, e.putMessage(owned, id, memberID, fmt.Sprintf("message %d", id))) + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(e.keys, owned, id).Result) + } + + // Every member may read the pins, not only who may pin. + var ( + got []*chatpb.Pin + token *commonpb.PagingToken + ) + for page := 0; ; page++ { + resp := e.mustGetPinnedMessages(memberKeys, owned, messagingpb.ViewMode_FULL, &commonpb.QueryOptions{PageSize: 2, PagingToken: token}) + require.Equal(t, chatpb.GetPinnedMessagesResponse_OK, resp.Result) + requirePinSummaryProto(t, resp.PinSummary, 5, 5, messages[4], e.userID) + got = append(got, resp.Pins...) + if !resp.HasMore { + require.Nil(t, resp.PagingToken) + require.Len(t, resp.Pins, 1) + require.Equal(t, 2, page) + break + } + require.Len(t, resp.Pins, 2) + require.NotNil(t, resp.PagingToken) + token = resp.PagingToken + } + require.Len(t, got, 5) + for i, pin := range got { + requirePinProto(t, pin, messages[4-i], e.userID) + } + + // With no page size the whole list fits one page. + resp := e.mustGetPinnedMessages(e.keys, owned, messagingpb.ViewMode_FULL, nil) + require.Len(t, resp.Pins, 5) + require.False(t, resp.HasMore) + + // A token minted for one chat is refused for another. + first := e.mustGetPinnedMessages(e.keys, owned, messagingpb.ViewMode_FULL, &commonpb.QueryOptions{PageSize: 2}) + require.True(t, first.HasMore) + _, err := e.getPinnedMessages(e.keys, other, messagingpb.ViewMode_FULL, &commonpb.QueryOptions{PagingToken: first.PagingToken}) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + _, err = e.getPinnedMessages(e.keys, owned, messagingpb.ViewMode_FULL, &commonpb.QueryOptions{PagingToken: &commonpb.PagingToken{Value: []byte{1, 2, 3}}}) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + + // A pinned message deleted since is shown as its tombstone. + e.deleteMessage(owned, 5) + resp = e.mustGetPinnedMessages(e.keys, owned, messagingpb.ViewMode_FULL, &commonpb.QueryOptions{PageSize: 1}) + require.Equal(t, chatpb.GetPinnedMessagesResponse_OK, resp.Result) + require.NotNil(t, resp.Pins[0].Message.Content[0].GetDeleted()) + require.NotNil(t, resp.PinSummary.Latest.Message.Content[0].GetDeleted()) + + // A DM's pins page the same way, to its members alone. + peerID, peerKeys := e.addUser() + _, strangerKeys := e.addUser() + dm := e.putDMWithPeer(chatpb.ChatType_DM, peerID, at(1)) + dmMessage := e.putMessage(dm, 1, peerID, "dm") + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(peerKeys, dm, 1).Result) + resp = e.mustGetPinnedMessages(e.keys, dm, messagingpb.ViewMode_FULL, nil) + require.Equal(t, chatpb.GetPinnedMessagesResponse_OK, resp.Result) + require.Len(t, resp.Pins, 1) + requirePinProto(t, resp.Pins[0], dmMessage, peerID) + for _, mode := range []messagingpb.ViewMode{messagingpb.ViewMode_FULL, messagingpb.ViewMode_FULL_OR_REDACTED, messagingpb.ViewMode_REDACTED} { + require.Equal(t, chatpb.GetPinnedMessagesResponse_DENIED, e.mustGetPinnedMessages(strangerKeys, dm, mode, nil).Result) + } +} + +// testServer_GetPinnedMessages_Reading pins that pins are read as the chat's +// messages are (see messaging's GetMessages): a public group's non-member who +// may not read it in full gets them redacted where the mode allows it and is +// DENIED under FULL, and a private group's non-member is DENIED under every +// mode. +func testServer_GetPinnedMessages_Reading(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + founderID, founderKeys := e.addUser() + group := &chat.Chat{ + ID: chat.MustGenerateGroupChatID(), + Type: chatpb.ChatType_GROUP, + Members: []*commonpb.UserId{founderID}, + CreatorID: founderID, + Title: "Whales", + MinimumListenerBalance: &chat.MinimumBalance{Currency: "usd", NativeAmount: 100}, + LastActivity: at(1), + } + require.NoError(t, s.PutChat(e.ctx, group)) + const secret = "meet at the old bank at six" + msg := e.putMessage(group.ID, 1, founderID, secret) + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(founderKeys, group.ID, 1).Result) + + // The env user has no account and so fails the listener rules. + require.Equal(t, chatpb.GetPinnedMessagesResponse_DENIED, e.mustGetPinnedMessages(e.keys, group.ID, messagingpb.ViewMode_FULL, nil).Result) + for _, mode := range []messagingpb.ViewMode{messagingpb.ViewMode_FULL_OR_REDACTED, messagingpb.ViewMode_REDACTED} { + resp := e.mustGetPinnedMessages(e.keys, group.ID, mode, nil) + require.Equal(t, chatpb.GetPinnedMessagesResponse_OK, resp.Result) + require.Len(t, resp.Pins, 1) + for _, m := range []*messagingpb.Message{resp.Pins[0].Message, resp.PinSummary.Latest.Message} { + require.True(t, m.Redacted) + require.Equal(t, msg.MessageId.Value, m.MessageId.Value) + require.NotEqual(t, secret, m.Content[0].GetText().GetText()) + } + } + + // A member reads them in full, and redacted only on request. + resp := e.mustGetPinnedMessages(founderKeys, group.ID, messagingpb.ViewMode_FULL, nil) + require.Equal(t, chatpb.GetPinnedMessagesResponse_OK, resp.Result) + requirePinProto(t, resp.Pins[0], msg, founderID) + resp = e.mustGetPinnedMessages(founderKeys, group.ID, messagingpb.ViewMode_REDACTED, nil) + require.True(t, resp.Pins[0].Message.Redacted) + + // A private group's non-member is denied under every mode. + memberID, memberKeys := e.addUser() + private := e.putKeyedPrivateGroup("Private", memberID) + e.putMessage(private, 1, memberID, "ciphertext") + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(e.keys, private, 1).Result) + _, strangerKeys := e.addUser() + for _, mode := range []messagingpb.ViewMode{messagingpb.ViewMode_FULL, messagingpb.ViewMode_FULL_OR_REDACTED, messagingpb.ViewMode_REDACTED} { + require.Equal(t, chatpb.GetPinnedMessagesResponse_DENIED, e.mustGetPinnedMessages(strangerKeys, private, mode, nil).Result) + } + resp = e.mustGetPinnedMessages(memberKeys, private, messagingpb.ViewMode_FULL, nil) + require.Equal(t, chatpb.GetPinnedMessagesResponse_OK, resp.Result) + require.Len(t, resp.Pins, 1) +} + +// testServer_Pin_MissingMessage pins the rule that every Pin carries its +// message: a pinned message the read does not return fails the call that +// would show it with Internal, and the calls that show it not are unaffected. +func testServer_Pin_MissingMessage(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + memberID, _ := e.addUser() + owned := e.putOwnedGroup("Owned", nil, memberID) + e.putMessage(owned, 1, memberID, "first") + e.putMessage(owned, 2, memberID, "second") + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(e.keys, owned, 1).Result) + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(e.keys, owned, 2).Result) + e.waitForGroupPinsChanged(owned, 2) + + // The latest pin's message goes missing from the read. + delete(e.messaging.messages, chat.MessageKeyOf(owned, &messagingpb.MessageId{Value: 2})) + + _, err := e.getPinnedMessages(e.keys, owned, messagingpb.ViewMode_FULL, nil) + require.Equal(t, codes.Internal, status.Code(err)) + + // An unpin that leaves it the latest lands, but cannot show its summary, + // and so publishes nothing either. + _, err = e.tryUnpinMessage(e.keys, owned, 1) + require.Equal(t, codes.Internal, status.Code(err)) + e.waitForGroupPinsChanged(owned, 2) + + // Unpinning it leaves nothing missing to show. + resp := e.unpinMessage(e.keys, owned, 2) + require.Equal(t, chatpb.UnpinMessageResponse_OK, resp.Result) + requirePinSummaryProto(t, resp.PinSummary, 0, 4, nil, nil) + e.waitForGroupPinsChanged(owned, 3) +} + +// testServer_PinSummary_Hydration pins Metadata.pin_summary on the chat's +// full-detail carriers: the zero summary while nothing is pinned, then the +// summary with its latest pin's message as pins and unpins move it, a deleted +// latest as its tombstone, and never on a feed, which may omit it. +func testServer_PinSummary_Hydration(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + memberID, memberKeys := e.addUser() + peerID, peerKeys := e.addUser() + owned := e.putOwnedGroup("Owned", nil, memberID) + dm := e.putDMWithPeer(chatpb.ChatType_DM, peerID, at(1)) + + // Nothing pinned: the zero summary, for a group and a DM alike. + requirePinSummaryProto(t, e.getChat(e.keys, owned).Metadata.PinSummary, 0, 0, nil, nil) + requirePinSummaryProto(t, e.getChat(memberKeys, owned).Metadata.PinSummary, 0, 0, nil, nil) + requirePinSummaryProto(t, e.getChat(e.keys, dm).Metadata.PinSummary, 0, 0, nil, nil) + + first := e.putMessage(owned, 1, memberID, "first") + second := e.putMessage(owned, 2, e.userID, "second") + dmMessage := e.putMessage(dm, 1, peerID, "dm") + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(e.keys, owned, 1).Result) + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(e.keys, owned, 2).Result) + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(peerKeys, dm, 1).Result) + + // GetChat carries the summary, its latest with its message, to every + // member. + for _, keys := range []model.KeyPair{e.keys, memberKeys} { + requirePinSummaryProto(t, e.getChat(keys, owned).Metadata.PinSummary, 2, 2, second, e.userID) + } + for _, keys := range []model.KeyPair{e.keys, peerKeys} { + requirePinSummaryProto(t, e.getChat(keys, dm).Metadata.PinSummary, 1, 1, dmMessage, peerID) + } + + // So does every other full-detail carrier: an edit and a join. + title := "Renamed" + edited := e.mustEditChat(e.keys, owned, &title, nil) + require.Equal(t, chatpb.EditChatResponse_OK, edited.Result) + requirePinSummaryProto(t, edited.Chat.PinSummary, 2, 2, second, e.userID) + require.Equal(t, chatpb.LeaveChatResponse_OK, e.mustLeaveChat(memberKeys, owned).Result) + joined := e.mustJoinChat(memberKeys, owned) + require.Equal(t, chatpb.JoinChatResponse_OK, joined.Result) + requirePinSummaryProto(t, joined.Chat.PinSummary, 2, 2, second, e.userID) + + // The feeds are list views, and carry none. + groups := e.mustGetGroupFeed(&commonpb.QueryOptions{}) + require.Len(t, groups.Chats, 1) + require.Equal(t, owned.Value, groups.Chats[0].ChatId.Value) + require.Nil(t, groups.Chats[0].PinSummary) + dms, err := e.getDmFeedOfType(chatpb.ChatType_DM, &commonpb.QueryOptions{}) + require.NoError(t, err) + require.Len(t, dms.Chats, 1) + require.Equal(t, dm.Value, dms.Chats[0].ChatId.Value) + require.Nil(t, dms.Chats[0].PinSummary) + + // Unpins move it: the one before becomes the latest, then none is left. + require.Equal(t, chatpb.UnpinMessageResponse_OK, e.unpinMessage(e.keys, owned, 2).Result) + requirePinSummaryProto(t, e.getChat(e.keys, owned).Metadata.PinSummary, 1, 3, first, e.userID) + require.Equal(t, chatpb.UnpinMessageResponse_OK, e.unpinMessage(e.keys, owned, 1).Result) + requirePinSummaryProto(t, e.getChat(e.keys, owned).Metadata.PinSummary, 0, 4, nil, nil) + + // A latest pinned message deleted since is shown as its tombstone. + e.deleteMessage(dm, 1) + summary := e.getChat(e.keys, dm).Metadata.PinSummary + require.Equal(t, uint32(1), summary.Count) + require.NotNil(t, summary.Latest.Message.Content[0].GetDeleted()) +} + +// testServer_PinSummary_Reading pins that the pin summary is messaging state: +// shown to whoever may read the chat's messages, its latest message redacted +// under a redacted reading, the unauthenticated public view's included, and +// withheld with the rest of the messaging state from a viewer whose reading +// is denied. +func testServer_PinSummary_Reading(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + founderID, founderKeys := e.addUser() + group := &chat.Chat{ + ID: chat.MustGenerateGroupChatID(), + Type: chatpb.ChatType_GROUP, + Members: []*commonpb.UserId{founderID}, + CreatorID: founderID, + Title: "Whales", + MinimumListenerBalance: &chat.MinimumBalance{Currency: "usd", NativeAmount: 100}, + LastActivity: at(1), + } + require.NoError(t, s.PutChat(e.ctx, group)) + const secret = "meet at the old bank at six" + msg := e.putMessage(group.ID, 1, founderID, secret) + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(founderKeys, group.ID, 1).Result) + + requireRedacted := func(summary *chatpb.PinSummary) { + t.Helper() + require.NotNil(t, summary) + require.Equal(t, uint32(1), summary.Count) + require.Equal(t, uint64(1), summary.Version) + require.Equal(t, msg.MessageId.Value, summary.Latest.MessageId.Value) + require.Equal(t, founderID.Value, summary.Latest.PinnedBy.Value) + require.True(t, summary.Latest.Message.Redacted) + require.Equal(t, msg.MessageId.Value, summary.Latest.Message.MessageId.Value) + require.NotEqual(t, secret, summary.Latest.Message.Content[0].GetText().GetText()) + } + + // The env user has no account and so fails the listener rules: under + // FULL their reading is denied, and the record comes with no summary. + resp := e.getChatWithMode(e.keys, group.ID, messagingpb.ViewMode_FULL) + require.Equal(t, chatpb.GetChatResponse_OK, resp.Result) + require.Nil(t, resp.Metadata.PinSummary) + for _, mode := range []messagingpb.ViewMode{messagingpb.ViewMode_FULL_OR_REDACTED, messagingpb.ViewMode_REDACTED} { + resp := e.getChatWithMode(e.keys, group.ID, mode) + require.Equal(t, chatpb.GetChatResponse_OK, resp.Result) + requireRedacted(resp.Metadata.PinSummary) + } + + // The public view is a redacted read. + resp = e.getPublicChat(group.ID, messagingpb.ViewMode_REDACTED) + require.Equal(t, chatpb.GetChatResponse_OK, resp.Result) + requireRedacted(resp.Metadata.PinSummary) + + // A member reads it in full, and redacted only on request. + requirePinSummaryProto(t, e.getChat(founderKeys, group.ID).Metadata.PinSummary, 1, 1, msg, founderID) + requireRedacted(e.getChatWithMode(founderKeys, group.ID, messagingpb.ViewMode_REDACTED).Metadata.PinSummary) + + // A private group's non-member, signed in or not, is shown the record + // alone; its members, the summary. + memberID, memberKeys := e.addUser() + private := e.putKeyedPrivateGroup("Private", memberID) + privateMessage := e.putMessage(private, 1, memberID, "ciphertext") + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(e.keys, private, 1).Result) + _, strangerKeys := e.addUser() + for _, mode := range []messagingpb.ViewMode{messagingpb.ViewMode_FULL, messagingpb.ViewMode_FULL_OR_REDACTED, messagingpb.ViewMode_REDACTED} { + resp := e.getChatWithMode(strangerKeys, private, mode) + require.Equal(t, chatpb.GetChatResponse_OK, resp.Result) + require.Nil(t, resp.Metadata.PinSummary) + } + resp = e.getPublicChat(private, messagingpb.ViewMode_REDACTED) + require.Equal(t, chatpb.GetChatResponse_OK, resp.Result) + require.Nil(t, resp.Metadata.PinSummary) + requirePinSummaryProto(t, e.getChat(memberKeys, private).Metadata.PinSummary, 1, 1, privateMessage, e.userID) +} + +// testServer_PinSummary_MissingMessage pins the rule that every Pin carries +// its message on a chat's metadata too: a latest pinned message the read does +// not return fails GetChat with Internal, and a feed, which shows no summary, +// is unaffected. +func testServer_PinSummary_MissingMessage(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + memberID, _ := e.addUser() + owned := e.putOwnedGroup("Owned", nil, memberID) + e.putMessage(owned, 1, memberID, "first") + require.Equal(t, chatpb.PinMessageResponse_OK, e.pinMessage(e.keys, owned, 1).Result) + + delete(e.messaging.messages, chat.MessageKeyOf(owned, &messagingpb.MessageId{Value: 1})) + + req := &chatpb.GetChatRequest{ChatId: owned} + require.NoError(t, e.keys.Auth(req, &req.Auth)) + _, err := e.client.GetChat(e.ctx, req) + require.Equal(t, codes.Internal, status.Code(err)) + + feed := e.mustGetGroupFeed(&commonpb.QueryOptions{}) + require.Len(t, feed.Chats, 1) + require.Nil(t, feed.Chats[0].PinSummary) + + // Unpinning it leaves nothing missing to show. + require.Equal(t, chatpb.UnpinMessageResponse_OK, e.unpinMessage(e.keys, owned, 1).Result) + requirePinSummaryProto(t, e.getChat(e.keys, owned).Metadata.PinSummary, 0, 2, nil, nil) +} diff --git a/chat/tests/store.go b/chat/tests/store.go index 670c740..d2447ae 100644 --- a/chat/tests/store.go +++ b/chat/tests/store.go @@ -138,6 +138,18 @@ func RunStoreTests(t *testing.T, s chat.Store, newStore func(excludedFromFeed [] testStore_InviteJoin_Status, testStore_InviteJoin_Banned, testStore_InviteJoin_Lobby, + testStore_Pin_Empty, + testStore_Pin_PinAndGet, + testStore_Pin_RepinNoOp, + testStore_Pin_Order, + testStore_Pin_LatestByPosition, + testStore_Pin_Unpin, + testStore_Pin_UnpinThenRepin, + testStore_Pin_Cap, + testStore_Pin_Invalid, + testStore_Pin_ChatsIndependent, + testStore_Pin_ConcurrentCap, + testStore_Pin_ConcurrentSameMessage, testStore_InviteJoin_Envelope, testStore_InviteJoin_Revoke, testStore_InviteJoin_Concurrent, @@ -6536,3 +6548,574 @@ func testStore_InviteJoin_RaceBan(t *testing.T, s chat.Store) { requireLobbyFreed(t, s, groupID, user) }) } + +// RunPinStoreTests runs the shared tests of a chat.Store's pins (see +// chat.Pin) against s, alone. They are part of RunStoreTests; this runs them +// for a store with no wrapper of its own for the whole suite, such as the +// cache over memory. teardown is called between tests to reset the store; +// every test names chats of its own, so a store that shares storage with +// another is safe. +func RunPinStoreTests(t *testing.T, s chat.Store, teardown func()) { + for _, tf := range []func(t *testing.T, s chat.Store){ + testStore_Pin_Empty, + testStore_Pin_PinAndGet, + testStore_Pin_RepinNoOp, + testStore_Pin_Order, + testStore_Pin_LatestByPosition, + testStore_Pin_Unpin, + testStore_Pin_UnpinThenRepin, + testStore_Pin_Cap, + testStore_Pin_Invalid, + testStore_Pin_ChatsIndependent, + testStore_Pin_ConcurrentCap, + testStore_Pin_ConcurrentSameMessage, + } { + tf(t, s) + teardown() + } +} + +// pinAt is a pin time with nanoseconds, so a store that dropped precision +// would be caught. +func pinAt(seconds int64) time.Time { + return time.Unix(1_700_000_000+seconds, 123_456_789).UTC() +} + +func messageID(v uint64) *messagingpb.MessageId { + return &messagingpb.MessageId{Value: v} +} + +// requirePin checks that got is want exactly: its message, its pinner, and +// its time to the nanosecond, in UTC. +func requirePin(t *testing.T, want, got chat.Pin) { + t.Helper() + require.NotNil(t, got.MessageID) + require.Equal(t, want.MessageID.Value, got.MessageID.Value) + require.NotNil(t, got.PinnedBy) + require.Equal(t, want.PinnedBy.Value, got.PinnedBy.Value) + require.Equal(t, want.PinnedAt, got.PinnedAt) +} + +// requirePinSummary checks a summary against the latest pin expected (nil +// for none), the count and the version. +func requirePinSummary(t *testing.T, got chat.PinSummary, latest *chat.Pin, count uint32, version uint64) { + t.Helper() + require.Equal(t, count, got.Count) + require.Equal(t, version, got.Version) + if latest == nil { + require.Nil(t, got.Latest) + return + } + require.NotNil(t, got.Latest) + requirePin(t, *latest, *got.Latest) +} + +// requireStoredPinSummary checks the summary GetPinSummaries reads for the +// chat, as requirePinSummary does. +func requireStoredPinSummary(t *testing.T, s chat.Store, chatID *commonpb.ChatId, latest *chat.Pin, count uint32, version uint64) { + t.Helper() + summaries, err := s.GetPinSummaries(context.Background(), []*commonpb.ChatId{chatID}) + require.NoError(t, err) + got, ok := summaries[string(chatID.Value)] + require.True(t, ok) + requirePinSummary(t, got, latest, count, version) +} + +// requirePins checks that the chat's pins, read whole, are want in order. +func requirePins(t *testing.T, s chat.Store, chatID *commonpb.ChatId, want ...chat.Pin) { + t.Helper() + got, err := s.GetPinsPage(context.Background(), chatID, nil, 0) + require.NoError(t, err) + require.Len(t, got, len(want)) + for i := range want { + requirePin(t, want[i], got[i]) + } +} + +// pinMessage pins pin in chatID under maxPins, requiring it to be a real +// pin. +func pinMessage(t *testing.T, s chat.Store, chatID *commonpb.ChatId, pin chat.Pin, maxPins int) chat.PinSummary { + t.Helper() + summary, changed, err := s.PinMessage(context.Background(), chatID, pin.MessageID, pin.PinnedBy, pin.PinnedAt, maxPins) + require.NoError(t, err) + require.True(t, changed) + return summary +} + +func testStore_Pin_Empty(t *testing.T, s chat.Store) { + ctx := context.Background() + + for _, chatID := range []*commonpb.ChatId{generateDmChatID(), chat.MustGenerateGroupChatID()} { + // A chat that has never had a pin is absent from the summaries. + summaries, err := s.GetPinSummaries(ctx, []*commonpb.ChatId{chatID}) + require.NoError(t, err) + require.Empty(t, summaries) + + page, err := s.GetPinsPage(ctx, chatID, nil, 10) + require.NoError(t, err) + require.Empty(t, page) + + // Unpinning in it is a no-op that creates nothing. + summary, changed, err := s.UnpinMessage(ctx, chatID, messageID(1)) + require.NoError(t, err) + require.False(t, changed) + requirePinSummary(t, summary, nil, 0, 0) + summaries, err = s.GetPinSummaries(ctx, []*commonpb.ChatId{chatID}) + require.NoError(t, err) + require.Empty(t, summaries) + } + + summaries, err := s.GetPinSummaries(ctx, nil) + require.NoError(t, err) + require.Empty(t, summaries) +} + +func testStore_Pin_PinAndGet(t *testing.T, s chat.Store) { + ctx := context.Background() + + for _, chatID := range []*commonpb.ChatId{generateDmChatID(), chat.MustGenerateGroupChatID()} { + pin := chat.Pin{MessageID: messageID(7), PinnedBy: model.MustGenerateUserID(), PinnedAt: pinAt(10)} + summary := pinMessage(t, s, chatID, pin, chat.MaxPinnedMessages) + requirePinSummary(t, summary, &pin, 1, 1) + requireStoredPinSummary(t, s, chatID, &pin, 1, 1) + requirePins(t, s, chatID, pin) + } + + // A time in another zone, or with a monotonic reading, is recorded as + // the same instant in UTC. + chatID := chat.MustGenerateGroupChatID() + local := pinAt(20).In(time.FixedZone("x", 3600)) + pinnedBy := model.MustGenerateUserID() + summary, changed, err := s.PinMessage(ctx, chatID, messageID(1), pinnedBy, local, chat.MaxPinnedMessages) + require.NoError(t, err) + require.True(t, changed) + want := chat.Pin{MessageID: messageID(1), PinnedBy: pinnedBy, PinnedAt: pinAt(20)} + requirePinSummary(t, summary, &want, 1, 1) + + chatID = chat.MustGenerateGroupChatID() + now := time.Now() + _, changed, err = s.PinMessage(ctx, chatID, messageID(1), pinnedBy, now, chat.MaxPinnedMessages) + require.NoError(t, err) + require.True(t, changed) + requirePins(t, s, chatID, chat.Pin{MessageID: messageID(1), PinnedBy: pinnedBy, PinnedAt: chat.PinTime(now)}) +} + +func testStore_Pin_RepinNoOp(t *testing.T, s chat.Store) { + ctx := context.Background() + chatID := chat.MustGenerateGroupChatID() + + a := chat.Pin{MessageID: messageID(1), PinnedBy: model.MustGenerateUserID(), PinnedAt: pinAt(1)} + b := chat.Pin{MessageID: messageID(2), PinnedBy: model.MustGenerateUserID(), PinnedAt: pinAt(2)} + pinMessage(t, s, chatID, a, 2) + pinMessage(t, s, chatID, b, 2) + + // Pinning a again, by someone else and later, changes nothing: a keeps + // its time, its pinner and its place below b, and the version holds. The + // chat is at the cap, and a lower one, which the no-op is judged before. + for _, maxPins := range []int{chat.MaxPinnedMessages, 2, 1} { + summary, changed, err := s.PinMessage(ctx, chatID, a.MessageID, model.MustGenerateUserID(), pinAt(3), maxPins) + require.NoError(t, err) + require.False(t, changed) + requirePinSummary(t, summary, &b, 2, 2) + } + requireStoredPinSummary(t, s, chatID, &b, 2, 2) + requirePins(t, s, chatID, b, a) + + // The latest pinned again is a no-op too. + summary, changed, err := s.PinMessage(ctx, chatID, b.MessageID, b.PinnedBy, pinAt(4), 2) + require.NoError(t, err) + require.False(t, changed) + requirePinSummary(t, summary, &b, 2, 2) + requirePins(t, s, chatID, b, a) +} + +func testStore_Pin_Order(t *testing.T, s chat.Store) { + ctx := context.Background() + chatID := chat.MustGenerateGroupChatID() + pinnedBy := model.MustGenerateUserID() + + // Pinned out of order, with three pins at one instant. + pins := []chat.Pin{ + {MessageID: messageID(10), PinnedBy: pinnedBy, PinnedAt: pinAt(5)}, + {MessageID: messageID(3), PinnedBy: pinnedBy, PinnedAt: pinAt(9)}, + {MessageID: messageID(4), PinnedBy: pinnedBy, PinnedAt: pinAt(7)}, + {MessageID: messageID(8), PinnedBy: pinnedBy, PinnedAt: pinAt(7)}, + {MessageID: messageID(1), PinnedBy: pinnedBy, PinnedAt: pinAt(1)}, + {MessageID: messageID(6), PinnedBy: pinnedBy, PinnedAt: pinAt(7)}, + } + const maxPins = 10 + for _, pin := range pins { + pinMessage(t, s, chatID, pin, maxPins) + } + + // Most recently pinned first, the tie at 7 by message ID descending. + want := []chat.Pin{pins[1], pins[3], pins[5], pins[2], pins[0], pins[4]} + requirePins(t, s, chatID, want...) + requireStoredPinSummary(t, s, chatID, &want[0], uint32(len(want)), uint64(len(want))) + + // Each page resumes strictly after the last pin of the one before, + // whatever its size. + for _, limit := range []int{1, 2, 4, len(want), len(want) + 5} { + var got []chat.Pin + var after *chat.PinPosition + for { + page, err := s.GetPinsPage(ctx, chatID, after, limit) + require.NoError(t, err) + require.LessOrEqual(t, len(page), limit) + if len(page) == 0 { + break + } + got = append(got, page...) + pos := page[len(page)-1].Position() + after = &pos + } + require.Len(t, got, len(want)) + for i := range want { + requirePin(t, want[i], got[i]) + } + } + + // A cursor naming each pin returns exactly the pins after it, the last + // none. + for i := range want { + pos := want[i].Position() + page, err := s.GetPinsPage(ctx, chatID, &pos, 0) + require.NoError(t, err) + require.Len(t, page, len(want)-i-1) + for j, pin := range page { + requirePin(t, want[i+1+j], pin) + } + } + + // A cursor naming no pin, inside the tie, still resumes in order: after + // (7, 7) come (7, 6) and the earlier times. + pos := chat.PinPosition{PinnedAt: pinAt(7), MessageID: messageID(7)} + page, err := s.GetPinsPage(ctx, chatID, &pos, 0) + require.NoError(t, err) + require.Len(t, page, 4) + for j, pin := range page { + requirePin(t, want[2+j], pin) + } + + // The cursor resumes after a pin removed since it was handed out. + _, changed, err := s.UnpinMessage(ctx, chatID, want[1].MessageID) + require.NoError(t, err) + require.True(t, changed) + pos = want[1].Position() + page, err = s.GetPinsPage(ctx, chatID, &pos, 2) + require.NoError(t, err) + require.Len(t, page, 2) + requirePin(t, want[2], page[0]) + requirePin(t, want[3], page[1]) +} + +func testStore_Pin_LatestByPosition(t *testing.T, s chat.Store) { + ctx := context.Background() + chatID := generateDmChatID() + pinnedBy := model.MustGenerateUserID() + + // The latest is the first pin in order, not the last one made: a pin + // with an earlier time than the latest leaves it the latest. + latest := chat.Pin{MessageID: messageID(5), PinnedBy: pinnedBy, PinnedAt: pinAt(10)} + requirePinSummary(t, pinMessage(t, s, chatID, latest, chat.MaxPinnedMessages), &latest, 1, 1) + earlier := chat.Pin{MessageID: messageID(9), PinnedBy: pinnedBy, PinnedAt: pinAt(3)} + requirePinSummary(t, pinMessage(t, s, chatID, earlier, chat.MaxPinnedMessages), &latest, 2, 2) + requireStoredPinSummary(t, s, chatID, &latest, 2, 2) + + // At the latest's instant, the smaller message ID comes after it and the + // greater before. + tieBelow := chat.Pin{MessageID: messageID(4), PinnedBy: pinnedBy, PinnedAt: pinAt(10)} + requirePinSummary(t, pinMessage(t, s, chatID, tieBelow, chat.MaxPinnedMessages), &latest, 3, 3) + tieAbove := chat.Pin{MessageID: messageID(6), PinnedBy: pinnedBy, PinnedAt: pinAt(10)} + requirePinSummary(t, pinMessage(t, s, chatID, tieAbove, chat.MaxPinnedMessages), &tieAbove, 4, 4) + requireStoredPinSummary(t, s, chatID, &tieAbove, 4, 4) + requirePins(t, s, chatID, tieAbove, latest, tieBelow, earlier) + + // Unpinning the latest makes the next in order the latest, however + // they were pinned. + summary, changed, err := s.UnpinMessage(ctx, chatID, tieAbove.MessageID) + require.NoError(t, err) + require.True(t, changed) + requirePinSummary(t, summary, &latest, 3, 5) + summary, changed, err = s.UnpinMessage(ctx, chatID, latest.MessageID) + require.NoError(t, err) + require.True(t, changed) + requirePinSummary(t, summary, &tieBelow, 2, 6) + requireStoredPinSummary(t, s, chatID, &tieBelow, 2, 6) +} + +func testStore_Pin_Unpin(t *testing.T, s chat.Store) { + ctx := context.Background() + chatID := generateDmChatID() + + a := chat.Pin{MessageID: messageID(1), PinnedBy: model.MustGenerateUserID(), PinnedAt: pinAt(1)} + b := chat.Pin{MessageID: messageID(2), PinnedBy: model.MustGenerateUserID(), PinnedAt: pinAt(2)} + c := chat.Pin{MessageID: messageID(3), PinnedBy: model.MustGenerateUserID(), PinnedAt: pinAt(3)} + for _, pin := range []chat.Pin{a, b, c} { + pinMessage(t, s, chatID, pin, chat.MaxPinnedMessages) + } + requireStoredPinSummary(t, s, chatID, &c, 3, 3) + + // Unpinning the latest makes the next in order the latest. + summary, changed, err := s.UnpinMessage(ctx, chatID, c.MessageID) + require.NoError(t, err) + require.True(t, changed) + requirePinSummary(t, summary, &b, 2, 4) + requireStoredPinSummary(t, s, chatID, &b, 2, 4) + requirePins(t, s, chatID, b, a) + + // A message not pinned, or no longer pinned, is a no-op. + for _, id := range []*messagingpb.MessageId{messageID(99), c.MessageID} { + summary, changed, err = s.UnpinMessage(ctx, chatID, id) + require.NoError(t, err) + require.False(t, changed) + requirePinSummary(t, summary, &b, 2, 4) + } + + // Unpinning one below the latest leaves the latest. + summary, changed, err = s.UnpinMessage(ctx, chatID, a.MessageID) + require.NoError(t, err) + require.True(t, changed) + requirePinSummary(t, summary, &b, 1, 5) + requirePins(t, s, chatID, b) + + // Unpinning the last leaves none, with the version kept: the chat has + // had pins, so it is no longer absent from the summaries. + summary, changed, err = s.UnpinMessage(ctx, chatID, b.MessageID) + require.NoError(t, err) + require.True(t, changed) + requirePinSummary(t, summary, nil, 0, 6) + requireStoredPinSummary(t, s, chatID, nil, 0, 6) + requirePins(t, s, chatID) + + summary, changed, err = s.UnpinMessage(ctx, chatID, b.MessageID) + require.NoError(t, err) + require.False(t, changed) + requirePinSummary(t, summary, nil, 0, 6) +} + +func testStore_Pin_UnpinThenRepin(t *testing.T, s chat.Store) { + ctx := context.Background() + chatID := chat.MustGenerateGroupChatID() + + a := chat.Pin{MessageID: messageID(1), PinnedBy: model.MustGenerateUserID(), PinnedAt: pinAt(1)} + b := chat.Pin{MessageID: messageID(2), PinnedBy: model.MustGenerateUserID(), PinnedAt: pinAt(2)} + pinMessage(t, s, chatID, a, chat.MaxPinnedMessages) + pinMessage(t, s, chatID, b, chat.MaxPinnedMessages) + + // Unpinned then pinned again, a is a fresh pin: its new time and pinner, + // at the top, two versions on. + _, changed, err := s.UnpinMessage(ctx, chatID, a.MessageID) + require.NoError(t, err) + require.True(t, changed) + again := chat.Pin{MessageID: a.MessageID, PinnedBy: model.MustGenerateUserID(), PinnedAt: pinAt(3)} + summary := pinMessage(t, s, chatID, again, chat.MaxPinnedMessages) + requirePinSummary(t, summary, &again, 2, 4) + requireStoredPinSummary(t, s, chatID, &again, 2, 4) + requirePins(t, s, chatID, again, b) +} + +func testStore_Pin_Cap(t *testing.T, s chat.Store) { + ctx := context.Background() + chatID := chat.MustGenerateGroupChatID() + pinnedBy := model.MustGenerateUserID() + + var pins []chat.Pin + for i := range chat.MaxPinnedMessages { + pin := chat.Pin{MessageID: messageID(uint64(i + 1)), PinnedBy: pinnedBy, PinnedAt: pinAt(int64(i))} + pinMessage(t, s, chatID, pin, chat.MaxPinnedMessages) + pins = append(pins, pin) + } + latest := pins[len(pins)-1] + + // One more is refused, with nothing written. + over := chat.Pin{MessageID: messageID(100), PinnedBy: pinnedBy, PinnedAt: pinAt(100)} + _, changed, err := s.PinMessage(ctx, chatID, over.MessageID, over.PinnedBy, over.PinnedAt, chat.MaxPinnedMessages) + require.ErrorIs(t, err, chat.ErrTooManyPins) + require.False(t, changed) + requireStoredPinSummary(t, s, chatID, &latest, chat.MaxPinnedMessages, chat.MaxPinnedMessages) + page, err := s.GetPinsPage(ctx, chatID, nil, 0) + require.NoError(t, err) + require.Len(t, page, chat.MaxPinnedMessages) + for _, pin := range page { + require.NotEqual(t, over.MessageID.Value, pin.MessageID.Value) + } + + // A lower cap than the chat holds refuses too. + _, _, err = s.PinMessage(ctx, chatID, over.MessageID, over.PinnedBy, over.PinnedAt, 2) + require.ErrorIs(t, err, chat.ErrTooManyPins) + + // After an unpin there is room again. + _, changed, err = s.UnpinMessage(ctx, chatID, pins[0].MessageID) + require.NoError(t, err) + require.True(t, changed) + summary := pinMessage(t, s, chatID, over, chat.MaxPinnedMessages) + requirePinSummary(t, summary, &over, chat.MaxPinnedMessages, chat.MaxPinnedMessages+2) + + // A cap below one is a programming error, with nothing written. + other := chat.MustGenerateGroupChatID() + for _, maxPins := range []int{0, -1} { + _, changed, err = s.PinMessage(ctx, other, messageID(1), pinnedBy, pinAt(1), maxPins) + require.Error(t, err) + require.NotErrorIs(t, err, chat.ErrTooManyPins) + require.False(t, changed) + } + summaries, err := s.GetPinSummaries(ctx, []*commonpb.ChatId{other}) + require.NoError(t, err) + require.Empty(t, summaries) +} + +func testStore_Pin_Invalid(t *testing.T, s chat.Store) { + ctx := context.Background() + chatID := chat.MustGenerateGroupChatID() + pinnedBy := model.MustGenerateUserID() + badChat := &commonpb.ChatId{Value: randomBytes(20)} + + for _, tc := range []struct { + name string + chatID *commonpb.ChatId + messageID *messagingpb.MessageId + pinnedBy *commonpb.UserId + pinnedAt time.Time + }{ + {"chat id length", badChat, messageID(1), pinnedBy, pinAt(1)}, + {"nil chat id", nil, messageID(1), pinnedBy, pinAt(1)}, + {"nil message id", chatID, nil, pinnedBy, pinAt(1)}, + {"nil pinner", chatID, messageID(1), nil, pinAt(1)}, + {"pinner length", chatID, messageID(1), &commonpb.UserId{Value: randomBytes(5)}, pinAt(1)}, + {"before the epoch", chatID, messageID(1), pinnedBy, time.Unix(-1, 0)}, + {"past unix nanos", chatID, messageID(1), pinnedBy, time.Date(2300, 1, 1, 0, 0, 0, 0, time.UTC)}, + } { + _, changed, err := s.PinMessage(ctx, tc.chatID, tc.messageID, tc.pinnedBy, tc.pinnedAt, chat.MaxPinnedMessages) + require.Error(t, err, tc.name) + require.False(t, changed, tc.name) + } + summaries, err := s.GetPinSummaries(ctx, []*commonpb.ChatId{chatID}) + require.NoError(t, err) + require.Empty(t, summaries) + + _, _, err = s.UnpinMessage(ctx, badChat, messageID(1)) + require.Error(t, err) + _, _, err = s.UnpinMessage(ctx, chatID, nil) + require.Error(t, err) + + _, err = s.GetPinSummaries(ctx, []*commonpb.ChatId{chatID, badChat}) + require.Error(t, err) + + _, err = s.GetPinsPage(ctx, badChat, nil, 0) + require.Error(t, err) + for _, pos := range []chat.PinPosition{ + {PinnedAt: pinAt(1)}, + {PinnedAt: time.Unix(-1, 0), MessageID: messageID(1)}, + } { + _, err = s.GetPinsPage(ctx, chatID, &pos, 0) + require.Error(t, err) + } +} + +func testStore_Pin_ChatsIndependent(t *testing.T, s chat.Store) { + ctx := context.Background() + dmID := generateDmChatID() + groupID := chat.MustGenerateGroupChatID() + neverID := chat.MustGenerateGroupChatID() + + // One message ID pinned in two chats is two pins. + dmPin := chat.Pin{MessageID: messageID(5), PinnedBy: model.MustGenerateUserID(), PinnedAt: pinAt(1)} + groupPin := chat.Pin{MessageID: messageID(5), PinnedBy: model.MustGenerateUserID(), PinnedAt: pinAt(2)} + groupPin2 := chat.Pin{MessageID: messageID(6), PinnedBy: model.MustGenerateUserID(), PinnedAt: pinAt(3)} + pinMessage(t, s, dmID, dmPin, 1) + pinMessage(t, s, groupID, groupPin, chat.MaxPinnedMessages) + pinMessage(t, s, groupID, groupPin2, chat.MaxPinnedMessages) + + // The DM at its cap of one does not hold the group back, nor the reverse. + summaries, err := s.GetPinSummaries(ctx, []*commonpb.ChatId{dmID, groupID, neverID, dmID}) + require.NoError(t, err) + require.Len(t, summaries, 2) + requirePinSummary(t, summaries[string(dmID.Value)], &dmPin, 1, 1) + requirePinSummary(t, summaries[string(groupID.Value)], &groupPin2, 2, 2) + + // Unpinning in one leaves the other. + _, changed, err := s.UnpinMessage(ctx, groupID, groupPin.MessageID) + require.NoError(t, err) + require.True(t, changed) + requireStoredPinSummary(t, s, dmID, &dmPin, 1, 1) + requirePins(t, s, dmID, dmPin) + requireStoredPinSummary(t, s, groupID, &groupPin2, 1, 3) + requirePins(t, s, groupID, groupPin2) +} + +func testStore_Pin_ConcurrentCap(t *testing.T, s chat.Store) { + ctx := context.Background() + chatID := chat.MustGenerateGroupChatID() + pinnedBy := model.MustGenerateUserID() + + const pinners = 20 + results := make([]error, pinners) + var wg sync.WaitGroup + for i := range pinners { + wg.Add(1) + go func() { + defer wg.Done() + _, _, results[i] = s.PinMessage(ctx, chatID, messageID(uint64(i+1)), pinnedBy, pinAt(int64(i)), chat.MaxPinnedMessages) + }() + } + wg.Wait() + + var pinned, refused int + for _, err := range results { + switch { + case err == nil: + pinned++ + case errors.Is(err, chat.ErrTooManyPins): + refused++ + default: + require.NoError(t, err) + } + } + require.Equal(t, chat.MaxPinnedMessages, pinned) + require.Equal(t, pinners-chat.MaxPinnedMessages, refused) + + summaries, err := s.GetPinSummaries(ctx, []*commonpb.ChatId{chatID}) + require.NoError(t, err) + summary := summaries[string(chatID.Value)] + require.EqualValues(t, chat.MaxPinnedMessages, summary.Count) + require.EqualValues(t, chat.MaxPinnedMessages, summary.Version) + page, err := s.GetPinsPage(ctx, chatID, nil, 0) + require.NoError(t, err) + require.Len(t, page, chat.MaxPinnedMessages) + requirePin(t, page[0], *summary.Latest) +} + +func testStore_Pin_ConcurrentSameMessage(t *testing.T, s chat.Store) { + ctx := context.Background() + chatID := generateDmChatID() + + const pinners = 20 + changes := make([]bool, pinners) + results := make([]error, pinners) + var wg sync.WaitGroup + for i := range pinners { + wg.Add(1) + go func() { + defer wg.Done() + _, changes[i], results[i] = s.PinMessage(ctx, chatID, messageID(1), model.MustGenerateUserID(), pinAt(int64(i)), chat.MaxPinnedMessages) + }() + } + wg.Wait() + + var changed int + for i := range pinners { + require.NoError(t, results[i]) + if changes[i] { + changed++ + } + } + require.Equal(t, 1, changed) + + summaries, err := s.GetPinSummaries(ctx, []*commonpb.ChatId{chatID}) + require.NoError(t, err) + summary := summaries[string(chatID.Value)] + require.EqualValues(t, 1, summary.Count) + require.EqualValues(t, 1, summary.Version) + requirePins(t, s, chatID, *summary.Latest) +} diff --git a/community/server_test.go b/community/server_test.go index 65fb9bb..4d78e9e 100644 --- a/community/server_test.go +++ b/community/server_test.go @@ -139,6 +139,10 @@ func (u unexpectedReaders) LastMessages(context.Context, []chat.MessageRef) (map return nil, u.fail("LastMessages") } +func (u unexpectedReaders) Messages(context.Context, []chat.MessageRef, chat.Reading) (map[chat.MessageKey]*messagingpb.Message, error) { + return nil, u.fail("Messages") +} + func (u unexpectedReaders) Pointers(context.Context, []chat.PointerRef) (map[string][]*messagingpb.Pointer, error) { return nil, u.fail("Pointers") } diff --git a/event/tests/server.go b/event/tests/server.go index 3e36fd9..66525cc 100644 --- a/event/tests/server.go +++ b/event/tests/server.go @@ -1259,6 +1259,34 @@ func testChatPreview(t *testing.T, accounts account.Store) { } receiveAll(publish(testEnv.server1, newMessageSentEvent(group, member, 5, "after the request")), fullStreams, redactedStreams) + // A pin reaches a full preview as it is, and a redacted one with the + // pinned message redacted, both where it is made and as the summary's + // latest, and everything else about the pin as it was. + pinned := publish(testEnv.server2, pinsChangedEvent(group, member, 1, "pin these words")) + for _, streamer := range fullStreams { + receiveFull(streamer, pinned) + } + sentChange := pinned.GetChatUpdate().GetMetadataUpdates()[0].GetPinsChanged() + for _, streamer := range redactedStreams { + got := receiveNextEvents(t, streamer) + require.Len(t, got, 1) + require.NoError(t, protoutil.ProtoEqualError(pinned.Id, got[0].Id)) + updates := got[0].GetChatUpdate().GetMetadataUpdates() + require.Len(t, updates, 1) + gotChange := updates[0].GetPinsChanged() + require.NotNil(t, gotChange) + require.Equal(t, sentChange.Summary.Count, gotChange.Summary.Count) + require.Equal(t, sentChange.Summary.Version, gotChange.Summary.Version) + for _, pin := range []*chatpb.Pin{gotChange.GetPinned(), gotChange.Summary.Latest} { + require.NotNil(t, pin) + require.NoError(t, protoutil.ProtoEqualError(sentChange.GetPinned().MessageId, pin.MessageId)) + require.NoError(t, protoutil.ProtoEqualError(sentChange.GetPinned().PinnedBy, pin.PinnedBy)) + require.NoError(t, protoutil.ProtoEqualError(sentChange.GetPinned().PinnedAt, pin.PinnedAt)) + require.True(t, pin.Message.GetRedacted()) + require.NotEqual(t, "pin these words", pin.Message.GetContent()[0].GetText().GetText()) + } + } + // The standing is the open's: staff who lose their flag mid-window keep // their full previews until the window closes, and are refused a new // one; other, who joins mid-window, keeps their redacted previews too, @@ -1449,6 +1477,41 @@ func newMessageSentEvent(chatID *commonpb.ChatId, sender *commonpb.UserId, seq u return e } +// pinsChangedEvent builds a chat update carrying the pin of message seq, a +// text message sent and pinned by member, as the chat's first and only pin. +func pinsChangedEvent(chatID *commonpb.ChatId, member *commonpb.UserId, seq uint64, text string) *eventpb.Event { + pinnedAt, sentAt := time.Now(), time.Now() + pin := func() *chatpb.Pin { + return &chatpb.Pin{ + MessageId: &messagingpb.MessageId{Value: seq}, + PinnedBy: member, + PinnedAt: timestamppb.New(pinnedAt), + Message: &messagingpb.Message{ + MessageId: &messagingpb.MessageId{Value: seq}, + SenderId: member, + Content: []*messagingpb.Content{{Type: &messagingpb.Content_Text{Text: &messagingpb.TextContent{Text: text}}}}, + Ts: timestamppb.New(sentAt), + EventSequence: seq, + }, + } + } + e := &eventpb.Event{ + Id: model.MustGenerateEventID(), + Ts: timestamppb.Now(), + Type: &eventpb.Event_ChatUpdate{ChatUpdate: &eventpb.ChatUpdate{ + Chat: chatID, + MetadataUpdates: []*chatpb.MetadataUpdate{{Kind: &chatpb.MetadataUpdate_PinsChanged_{PinsChanged: &chatpb.MetadataUpdate_PinsChanged{ + Change: &chatpb.MetadataUpdate_PinsChanged_Pinned{Pinned: pin()}, + Summary: &chatpb.PinSummary{Latest: pin(), Count: 1, Version: 1}, + }}}}, + }}, + } + if err := e.Validate(); err != nil { + panic(err) + } + return e +} + // rosterLeftEvent builds a chat update carrying a member's departure at the // given roster version. func rosterLeftEvent(chatID *commonpb.ChatId, subject *commonpb.UserId, version uint64) *eventpb.Event { diff --git a/go.mod b/go.mod index 9dae23d..dcfc94c 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.31.1-0.20261028181054-6de4f9adfd11 + github.com/code-payments/flipcash2-protobuf-api v1.31.1-0.20261029161806-0e167fa6c01e 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 e25a8be..ccd1801 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.31.1-0.20261028181054-6de4f9adfd11 h1:8MngNyf4Arcqd++sNSSiKCt7HCDDPNODYQZcwMcmYVs= -github.com/code-payments/flipcash2-protobuf-api v1.31.1-0.20261028181054-6de4f9adfd11/go.mod h1:s/1pOsb4FTRD+LcvRKGjfmm6ygRS/m1ep34EIW0fuDs= +github.com/code-payments/flipcash2-protobuf-api v1.31.1-0.20261029161806-0e167fa6c01e h1:yFNk1h5WJT1Bje3Xd8yEhuS9SqKYGGOPJQQPTSYdjIQ= +github.com/code-payments/flipcash2-protobuf-api v1.31.1-0.20261029161806-0e167fa6c01e/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= diff --git a/messaging/chat.go b/messaging/chat.go index 709867f..08f898c 100644 --- a/messaging/chat.go +++ b/messaging/chat.go @@ -3,6 +3,8 @@ package messaging import ( "context" + "go.uber.org/zap" + commonpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/common/v1" messagingpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/messaging/v1" @@ -10,17 +12,21 @@ import ( ) // chatMessagingReader adapts a messaging Store to chat.MessagingReader, the read -// slice the Chat service uses to hydrate feed metadata. It lives here (rather -// than in chat) because chat must not import messaging; messaging already -// depends on chat, so the adapter sits on this side of the boundary. +// slice the Chat service uses to hydrate feed metadata and to show the +// messages it has chosen (see Messages). It lives here (rather than in chat) +// because chat must not import messaging; messaging already depends on chat, +// so the adapter sits on this side of the boundary. type chatMessagingReader struct { + log *zap.Logger store Store + media Media } // NewChatMessagingReader returns a chat.MessagingReader backed by the given -// messaging store, for wiring the Chat service. -func NewChatMessagingReader(store Store) chat.MessagingReader { - return &chatMessagingReader{store: store} +// messaging store, resolving media through the given Media as the Messaging +// service's reads do, for wiring the Chat service. +func NewChatMessagingReader(log *zap.Logger, store Store, media Media) chat.MessagingReader { + return &chatMessagingReader{log: log, store: store, media: media} } func (r *chatMessagingReader) LastMessages(ctx context.Context, refs []chat.MessageRef) (map[string]*messagingpb.Message, error) { @@ -45,6 +51,34 @@ func (r *chatMessagingReader) LastMessages(ctx context.Context, refs []chat.Mess return out, nil } +// Messages reads the refs in one batch and presents them exactly as the +// Messaging service's reads do (see presentMessages). +func (r *chatMessagingReader) Messages(ctx context.Context, refs []chat.MessageRef, reading chat.Reading) (map[chat.MessageKey]*messagingpb.Message, error) { + if len(refs) == 0 { + return map[chat.MessageKey]*messagingpb.Message{}, nil + } + + storeRefs := make([]MessageRef, len(refs)) + for i, ref := range refs { + storeRefs[i] = MessageRef{ChatID: ref.ChatID, MessageID: ref.MessageID} + } + + msgs, err := r.store.GetMessagesByRefs(ctx, storeRefs) + if err != nil { + return nil, err + } + protos, err := presentMessages(ctx, r.log, r.media, reading, msgs) + if err != nil { + return nil, err + } + + out := make(map[chat.MessageKey]*messagingpb.Message, len(protos)) + for i, p := range protos { + out[chat.MessageKeyOf(msgs[i].ChatID, p.MessageId)] = p + } + return out, nil +} + func (r *chatMessagingReader) Pointers(ctx context.Context, refs []chat.PointerRef) (map[string][]*messagingpb.Pointer, error) { if len(refs) == 0 { return map[string][]*messagingpb.Pointer{}, nil diff --git a/messaging/chat_test.go b/messaging/chat_test.go index 2f3300a..b31f101 100644 --- a/messaging/chat_test.go +++ b/messaging/chat_test.go @@ -7,7 +7,9 @@ import ( "time" "github.com/stretchr/testify/require" + "go.uber.org/zap" + blobpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/blob/v1" commonpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/common/v1" messagingpb "github.com/code-payments/flipcash2-protobuf-api/generated/go/messaging/v1" @@ -20,7 +22,7 @@ import ( func TestChatMessagingReader(t *testing.T) { ctx := context.Background() store := memory.NewInMemory() - reader := messaging.NewChatMessagingReader(store) + reader := messaging.NewChatMessagingReader(zap.NewNop(), store, &fakeMedia{}) chatA := randomChatID() chatB := randomChatID() @@ -72,6 +74,111 @@ func TestChatMessagingReader(t *testing.T) { require.Empty(t, noSeqs) } +func TestChatMessagingReader_Messages(t *testing.T) { + ctx := context.Background() + store := memory.NewInMemory() + original := randomBlobID() + thumbnail := randomBlobID() + media := &fakeMedia{renditions: map[string][]*blobpb.Rendition{ + string(original.Value): { + {Role: blobpb.Rendition_ORIGINAL, BlobId: original}, + {Role: blobpb.Rendition_THUMBNAIL, BlobId: thumbnail}, + }, + }} + reader := messaging.NewChatMessagingReader(zap.NewNop(), store, media) + + chatA := randomChatID() + chatB := randomChatID() + sender := model.MustGenerateUserID() + + a1, _, err := store.PutMessage(ctx, chatA, sender, textContent("a1"), time.Unix(1, 0), randomClientID(), true) + require.NoError(t, err) + a2, _, err := store.PutMessage(ctx, chatA, sender, mediaContent(original), time.Unix(2, 0), randomClientID(), true) + require.NoError(t, err) + a3, _, err := store.PutMessage(ctx, chatA, sender, textContent("a3"), time.Unix(3, 0), randomClientID(), true) + require.NoError(t, err) + b1, _, err := store.PutMessage(ctx, chatB, sender, textContent("b1"), time.Unix(1, 0), randomClientID(), true) + require.NoError(t, err) + _, err = store.DeleteMessage(ctx, chatA, a3.ID, sender, time.Unix(4, 0), a3.EventSequence) + require.NoError(t, err) + + refs := []chat.MessageRef{ + {ChatID: chatA, MessageID: a1.ID}, + {ChatID: chatA, MessageID: a2.ID}, + {ChatID: chatA, MessageID: a3.ID}, + {ChatID: chatB, MessageID: b1.ID}, + {ChatID: chatB, MessageID: &messagingpb.MessageId{Value: 99}}, // missing + {ChatID: chatA, MessageID: a1.ID}, // repeated + } + + // Full: several messages of one chat and one of another, each under its + // own key, a missing ref absent, media resolved, a deleted message as its + // tombstone. + got, err := reader.Messages(ctx, refs, chat.ReadingFull) + require.NoError(t, err) + require.Len(t, got, 4) + require.Equal(t, "a1", got[chat.MessageKeyOf(chatA, a1.ID)].Content[0].GetText().Text) + require.Equal(t, "b1", got[chat.MessageKeyOf(chatB, b1.ID)].Content[0].GetText().Text) + require.Len(t, got[chat.MessageKeyOf(chatA, a2.ID)].Content[0].GetMedia().Items[0].Renditions, 2) + require.NotNil(t, got[chat.MessageKeyOf(chatA, a3.ID)].Content[0].GetDeleted()) + _, ok := got[chat.MessageKeyOf(chatB, &messagingpb.MessageId{Value: 99})] + require.False(t, ok) + for _, m := range got { + require.False(t, m.Redacted) + } + + // Redacted: every message redacted, media still resolved first. + redacted, err := reader.Messages(ctx, refs, chat.ReadingRedacted) + require.NoError(t, err) + require.Len(t, redacted, 4) + for _, m := range redacted { + require.True(t, m.Redacted) + } + require.NotEqual(t, "a1", redacted[chat.MessageKeyOf(chatA, a1.ID)].Content[0].GetText().Text) + require.Len(t, redacted[chat.MessageKeyOf(chatA, a2.ID)].Content[0].GetMedia().Items[0].Renditions, 2) + + // Empty refs → empty map, no error. + none, err := reader.Messages(ctx, nil, chat.ReadingFull) + require.NoError(t, err) + require.Empty(t, none) +} + +// fakeMedia resolves the renditions registered per ORIGINAL blob ID and +// shares nothing. +type fakeMedia struct { + renditions map[string][]*blobpb.Rendition +} + +func (f *fakeMedia) ShareIntoChat(context.Context, *commonpb.UserId, *commonpb.ChatId, []*blobpb.BlobId) error { + return nil +} + +func (f *fakeMedia) ResolveRenditions(_ context.Context, ids []*blobpb.BlobId) (map[string][]*blobpb.Rendition, error) { + out := make(map[string][]*blobpb.Rendition) + for _, id := range ids { + if r, ok := f.renditions[string(id.Value)]; ok { + out[string(id.Value)] = r + } + } + return out, nil +} + +func mediaContent(original *blobpb.BlobId) []*messagingpb.Content { + return []*messagingpb.Content{{ + Type: &messagingpb.Content_Media{Media: &messagingpb.MediaContent{ + Items: []*blobpb.Media{{Renditions: []*blobpb.Rendition{{Role: blobpb.Rendition_ORIGINAL, BlobId: original}}}}, + }}, + }} +} + +func randomBlobID() *blobpb.BlobId { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return &blobpb.BlobId{Value: b} +} + func textContent(text string) []*messagingpb.Content { return []*messagingpb.Content{{ Type: &messagingpb.Content_Text{Text: &messagingpb.TextContent{Text: text}}, diff --git a/messaging/delta.go b/messaging/delta.go index 3e239e8..b652be2 100644 --- a/messaging/delta.go +++ b/messaging/delta.go @@ -107,7 +107,7 @@ func (s *Server) GetDelta(req *messagingpb.GetDeltaRequest, stream messagingpb.M // A page may carry no surviving messages (all superseded) yet still advance // the checkpoint, so only attach a batch when there's something to apply. if len(msgs) > 0 { - batch, err := s.present(ctx, log, req.ChatId, reading, msgs) + batch, err := s.present(ctx, log, reading, msgs) if err != nil { return err } diff --git a/messaging/dynamodb/server_test.go b/messaging/dynamodb/server_test.go index 12cf386..923c97e 100644 --- a/messaging/dynamodb/server_test.go +++ b/messaging/dynamodb/server_test.go @@ -26,19 +26,20 @@ const ( featuredGroupsTable = "chat_featured_groups_test" holdingsTable = "chat_holdings_test" invitesTable = "chat_invites_test" + pinsTable = "chat_pins_test" badgesTable = "badges_test" ) func TestMessaging_DynamoDBServer(t *testing.T) { ctx := context.Background() - require.NoError(t, chat_dynamodb.CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable)) + require.NoError(t, chat_dynamodb.CreateTables(ctx, testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable)) require.NoError(t, CreateTables(ctx, testEnv.Client, messagesTable, pointersTable, reactionsTable, reactorsTable, selfReactionsTable, boostsTable)) require.NoError(t, badge_dynamodb.CreateTables(ctx, testEnv.Client, badgesTable)) badges := badge_dynamodb.NewInDynamoDB(testEnv.Client, badgesTable) blocklists := blocklist_memory.NewInMemory() - chats := chat_dynamodb.NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, nil) + chats := chat_dynamodb.NewInDynamoDB(testEnv.Client, chatsTable, dmInboxTable, groupMembersTable, userStateTable, activityTable, keyEnvelopesTable, lobbiesTable, featuredGroupsTable, holdingsTable, invitesTable, pinsTable, nil) profiles := profile_memory.NewInMemory() messages := NewInDynamoDB(testEnv.Client, messagesTable, pointersTable, reactionsTable, reactorsTable, selfReactionsTable, boostsTable) teardown := func() { diff --git a/messaging/message.go b/messaging/message.go index 4529ed1..fb1973b 100644 --- a/messaging/message.go +++ b/messaging/message.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "time" "go.uber.org/zap" @@ -49,7 +50,7 @@ func (s *Server) GetMessage(ctx context.Context, req *messagingpb.GetMessageRequ return nil, status.Error(codes.Internal, "") } - protos, err := s.present(ctx, log, req.ChatId, reading, []*Message{msg}) + protos, err := s.present(ctx, log, reading, []*Message{msg}) if err != nil { return nil, err } @@ -95,7 +96,7 @@ func (s *Server) GetMessages(ctx context.Context, req *messagingpb.GetMessagesRe if len(msgs) == 0 { return &messagingpb.GetMessagesResponse{Result: messagingpb.GetMessagesResponse_NOT_FOUND}, nil } - protos, err := s.present(ctx, log, req.ChatId, reading, msgs) + protos, err := s.present(ctx, log, reading, msgs) if err != nil { return nil, err } @@ -106,35 +107,49 @@ func (s *Server) GetMessages(ctx context.Context, req *messagingpb.GetMessagesRe } // present projects stored messages into what a read returns for the given -// reading: the protos with their media resolved (see hydrateMedia) and, for a -// redacted reading, every message replaced by its placeholder (see -// redact.Message). This is the one place a message read leaves the server, so -// every read path — GetMessage, GetMessages, GetDelta — answers a redacted -// viewer the same way, and a page is wholly full or wholly redacted. +// reading (see presentMessages), failing the read with Internal when a message +// cannot be redacted. +func (s *Server) present(ctx context.Context, log *zap.Logger, reading chat.Reading, msgs []*Message) ([]*messagingpb.Message, error) { + protos, err := presentMessages(ctx, log, s.media, reading, msgs) + if err != nil { + return nil, status.Error(codes.Internal, "") + } + return protos, nil +} + +// presentMessages projects stored messages into what a read returns for the +// given reading: the protos with their media resolved (see hydrateMedia) and, +// for a redacted reading, every message replaced by its placeholder (see +// redact.Message), each redacted as a message of its own chat. This is the +// one place a message read leaves the server — every read path of the +// Messaging service (GetMessage, GetMessages, GetDelta) and the Chat +// service's reads of chosen messages (see chatMessagingReader.Messages) — +// so all of them answer a redacted viewer the same way, and a page is wholly +// full or wholly redacted. // // Media is resolved before redaction, not skipped for it: the placeholder // keeps each rendition's intrinsic metadata — dimensions, blurhash — and drops // only the download URL, and the metadata is what resolution supplies. A // resolution failure is logged and the messages returned with their stored -// renditions, as before; a redaction failure fails the read with Internal, -// since a message that cannot be redacted must not be returned to a viewer -// who may only see it redacted. -func (s *Server) present(ctx context.Context, log *zap.Logger, chatID *commonpb.ChatId, reading chat.Reading, msgs []*Message) ([]*messagingpb.Message, error) { +// renditions, as before; a redaction failure is logged and returned as an +// error, since a message that cannot be redacted must not be returned to a +// viewer who may only see it redacted. +func presentMessages(ctx context.Context, log *zap.Logger, media Media, reading chat.Reading, msgs []*Message) ([]*messagingpb.Message, error) { protos := make([]*messagingpb.Message, len(msgs)) for i, m := range msgs { protos[i] = m.ToProto() } - if err := hydrateMedia(ctx, s.media, protos); err != nil { + if err := hydrateMedia(ctx, media, protos); err != nil { log.With(zap.Error(err)).Warn("Failure resolving media metadata") } if reading != chat.ReadingRedacted { return protos, nil } for i, p := range protos { - redacted, err := redact.Message(chatID, p) + redacted, err := redact.Message(msgs[i].ChatID, p) if err != nil { log.With(zap.Error(err), zap.Uint64("message_id", p.GetMessageId().GetValue())).Warn("Failure redacting message") - return nil, status.Error(codes.Internal, "") + return nil, fmt.Errorf("redacting message %d: %w", p.GetMessageId().GetValue(), err) } protos[i] = redacted } diff --git a/redact/event.go b/redact/event.go index 2a76acb..b330e9c 100644 --- a/redact/event.go +++ b/redact/event.go @@ -20,19 +20,20 @@ import ( // // The mode a viewer reads a chat under decides only the messages (see // eventpb.StreamEventsRequest.ChatParams), and an update carries them in -// three places: the mutations of its event-log events, the last_message of a -// metadata refresh, and the last_message of the metadata a MemberJoined hands -// its recipient. Pointer, typing, reaction and roster overlays pass through -// as they are — they describe the conversation's movement, which a redacted -// view shows, not its words. Payment request updates are left out: a viewer -// who may only preview a chat sees a request's widget but not the request -// (see paymentrequest/event.go), so an update carrying nothing else -// redacts to a bare one. Like Message, the copy is built field by field so -// that every field carried is an allowlist decision: a field added to -// ChatUpdate later is left out of the copy, and a new kind of mutation, -// metadata update or roster update is refused with an error, until a -// decision is made for it, rather than passed through to a viewer who may -// not read it. +// these places: the mutations of its event-log events; the last_message and +// the pin summary's latest pinned message of a metadata refresh's metadata, +// and of the metadata a MemberJoined hands its recipient; and the pin made +// and the summary's latest pinned message of a PinsChanged. Pointer, typing, +// reaction and roster overlays pass through as they are — they describe the +// conversation's movement, which a redacted view shows, not its words. +// Payment request updates are left out: a viewer who may only preview a chat +// sees a request's widget but not the request (see paymentrequest/event.go), +// so an update carrying nothing else redacts to a bare one. Like Message, the +// copy is built field by field so that every field carried is an allowlist +// decision: a field added to ChatUpdate later is left out of the copy, and a +// new kind of mutation, metadata update, pins change or roster update is +// refused with an error, until a decision is made for it, rather than passed +// through to a viewer who may not read it. // // The chat ID seeds every placeholder (see Message), so a message redacted on // the stream renders exactly as the same message redacted by GetMessages or @@ -123,18 +124,21 @@ func event(chatID *commonpb.ChatId, e *messagingpb.Event) (*messagingpb.Event, e } // metadataUpdate redacts one metadata update: a full refresh carries the -// chat's metadata, whose last_message is a message (see metadata); a -// last-activity change carries only a time; a viewer-state change carries -// the recipient's own state on the chat (a mute, their permissions and the -// version), which is theirs whatever they may read of the chat and holds no -// message, so it passes as it is; a title, description, profile picture or -// cover picture change carries the record's own fields, which identify the -// group to anyone who may read it in any form (see chat.v1.GetChat), so it -// passes as it is too; and a rules change carries the group's rules, which -// are no message's content and are shown to anyone who may see the group in -// any form, the public view's Metadata.rules included, since they are what a -// user weighs before joining, so it passes as it is as well. A kind this -// version does not know is an error. +// chat's metadata, whose last_message and pin summary carry messages (see +// metadata); a pins change carries the pin made, when it is a pin, and the +// summary after it, whose messages are redacted and nothing else (see pin), +// while an unpin names only the message unpinned; a last-activity change +// carries only a time; a viewer-state change carries the recipient's own +// state on the chat (a mute, their permissions and the version), which is +// theirs whatever they may read of the chat and holds no message, so it +// passes as it is; a title, description, profile picture or cover picture +// change carries the record's own fields, which identify the group to anyone +// who may read it in any form (see chat.v1.GetChat), so it passes as it is +// too; and a rules change carries the group's rules, which are no message's +// content and are shown to anyone who may see the group in any form, the +// public view's Metadata.rules included, since they are what a user weighs +// before joining, so it passes as it is as well. A kind this version does +// not know is an error. func metadataUpdate(chatID *commonpb.ChatId, md *chatpb.MetadataUpdate) (*chatpb.MetadataUpdate, error) { switch kind := md.GetKind().(type) { case *chatpb.MetadataUpdate_FullRefresh_: @@ -145,6 +149,23 @@ func metadataUpdate(chatID *commonpb.ChatId, md *chatpb.MetadataUpdate) (*chatpb return &chatpb.MetadataUpdate{Kind: &chatpb.MetadataUpdate_FullRefresh_{ FullRefresh: &chatpb.MetadataUpdate_FullRefresh{Metadata: redacted}, }}, nil + case *chatpb.MetadataUpdate_PinsChanged_: + out := proto.Clone(md).(*chatpb.MetadataUpdate) + change := out.GetPinsChanged() + switch c := change.GetChange().(type) { + case *chatpb.MetadataUpdate_PinsChanged_Pinned: + if err := pin(chatID, c.Pinned); err != nil { + return nil, err + } + case *chatpb.MetadataUpdate_PinsChanged_Unpinned: + // An ID alone, no message. + default: + return nil, fmt.Errorf("redact: unsupported pins change %T", c) + } + if err := pin(chatID, change.GetSummary().GetLatest()); err != nil { + return nil, err + } + return out, nil case *chatpb.MetadataUpdate_LastActivityChanged_, *chatpb.MetadataUpdate_ViewerStateChanged_, *chatpb.MetadataUpdate_TitleChanged_, @@ -183,8 +204,9 @@ func rosterUpdate(chatID *commonpb.ChatId, r *chatpb.RosterUpdate) (*chatpb.Rost } // metadata redacts a chat's metadata as chat.v1.GetChat does for a redacted -// viewer: the record as it is, with last_message — the one message it -// carries — redacted. A nil metadata is nil. +// viewer: the record as it is, with the messages it carries — last_message, +// and the pin summary's latest pinned message (see pin) — redacted. A nil +// metadata is nil. func metadata(chatID *commonpb.ChatId, md *chatpb.Metadata) (*chatpb.Metadata, error) { if md == nil { return nil, nil @@ -197,9 +219,30 @@ func metadata(chatID *commonpb.ChatId, md *chatpb.Metadata) (*chatpb.Metadata, e } out.LastMessage = redacted } + if err := pin(chatID, out.GetPinSummary().GetLatest()); err != nil { + return nil, err + } return out, nil } +// pin redacts, in place, the message a pin carries (see Message), so it is +// called only on a copy. Which message is pinned, by whom and when are kept, +// as are a summary's count and version around it: like a reaction, a pin is +// the conversation's movement, not its words, and a redacted message keeps +// its own ID and sender anyway. A nil pin, or one carrying no message, is +// left as it is. +func pin(chatID *commonpb.ChatId, p *chatpb.Pin) error { + if p.GetMessage() == nil { + return nil + } + redacted, err := Message(chatID, p.GetMessage()) + if err != nil { + return err + } + p.Message = redacted + return nil +} + func cloneOrNil(chatID *commonpb.ChatId) *commonpb.ChatId { if chatID == nil { return nil diff --git a/redact/event_test.go b/redact/event_test.go index ac0754f..d126b44 100644 --- a/redact/event_test.go +++ b/redact/event_test.go @@ -236,3 +236,133 @@ func TestChatUpdate_Refuses(t *testing.T) { _, err = ChatUpdate(chatID, unredactable) assert.ErrorIs(t, err, ErrUnsupportedContent) } + +// pinFixture is a pin of messageID carrying a text message, made by a member. +func pinFixture() *chatpb.Pin { + return &chatpb.Pin{ + MessageId: messageID, + PinnedBy: &commonpb.UserId{Value: bytes.Repeat([]byte{9}, 16)}, + PinnedAt: timestamppb.Now(), + Message: messageFixture(textFixture()), + } +} + +// requireRedactedPin asserts out is in with its message redacted and nothing +// else changed: which message, who pinned it and when. +func requireRedactedPin(t *testing.T, in, out *chatpb.Pin) { + t.Helper() + require.NotNil(t, out) + expected, err := Message(chatID, in.Message) + require.NoError(t, err) + assert.True(t, out.Message.Redacted) + assert.True(t, proto.Equal(expected, out.Message)) + assert.True(t, proto.Equal(in.MessageId, out.MessageId)) + assert.True(t, proto.Equal(in.PinnedBy, out.PinnedBy)) + assert.True(t, proto.Equal(in.PinnedAt, out.PinnedAt)) + assert.NotSame(t, in, out) +} + +// TestChatUpdate_Pins: every pinned message an update carries is redacted — +// the pin a PinsChanged makes, the latest of its summary, and the latest of +// the pin summary on a metadata refresh's or a join's metadata — and the rest +// of each pin and summary is kept. +func TestChatUpdate_Pins(t *testing.T) { + summary := func() *chatpb.PinSummary { + return &chatpb.PinSummary{Latest: pinFixture(), Count: 2, Version: 7} + } + in := &eventpb.ChatUpdate{ + Chat: chatID, + MetadataUpdates: []*chatpb.MetadataUpdate{ + {Kind: &chatpb.MetadataUpdate_PinsChanged_{PinsChanged: &chatpb.MetadataUpdate_PinsChanged{ + Change: &chatpb.MetadataUpdate_PinsChanged_Pinned{Pinned: pinFixture()}, + Summary: summary(), + }}}, + {Kind: &chatpb.MetadataUpdate_PinsChanged_{PinsChanged: &chatpb.MetadataUpdate_PinsChanged{ + Change: &chatpb.MetadataUpdate_PinsChanged_Unpinned{Unpinned: &messagingpb.MessageId{Value: 41}}, + Summary: summary(), + }}}, + {Kind: &chatpb.MetadataUpdate_PinsChanged_{PinsChanged: &chatpb.MetadataUpdate_PinsChanged{ + Change: &chatpb.MetadataUpdate_PinsChanged_Unpinned{Unpinned: &messagingpb.MessageId{Value: 42}}, + Summary: &chatpb.PinSummary{Version: 8}, + }}}, + {Kind: &chatpb.MetadataUpdate_FullRefresh_{FullRefresh: &chatpb.MetadataUpdate_FullRefresh{ + Metadata: &chatpb.Metadata{ChatId: chatID, Title: "Title", LastMessage: messageFixture(textFixture()), PinSummary: summary()}, + }}}, + }, + RosterUpdates: &chatpb.RosterUpdateBatch{RosterUpdates: []*chatpb.RosterUpdate{{ + Kind: &chatpb.RosterUpdate_MemberJoined_{MemberJoined: &chatpb.RosterUpdate_MemberJoined{ + Member: &chatpb.Member{UserId: &commonpb.UserId{Value: bytes.Repeat([]byte{6}, 16)}}, + Metadata: &chatpb.Metadata{ChatId: chatID, PinSummary: summary()}, + }}, + RosterSummary: &chatpb.RosterSummary{MemberCount: 2, Version: 5}, + }}}, + } + before := proto.Clone(in) + + out, err := ChatUpdate(chatID, in) + require.NoError(t, err) + assert.True(t, proto.Equal(before, in), "input modified") + require.Len(t, out.MetadataUpdates, 4) + + requireRedactedSummary := func(in, out *chatpb.PinSummary) { + t.Helper() + require.NotNil(t, out) + assert.Equal(t, in.Count, out.Count) + assert.Equal(t, in.Version, out.Version) + if in.Latest == nil { + assert.Nil(t, out.Latest) + return + } + requireRedactedPin(t, in.Latest, out.Latest) + } + + // A pin: the pin made and the summary's latest are redacted. + pinned := out.MetadataUpdates[0].GetPinsChanged() + requireRedactedPin(t, in.MetadataUpdates[0].GetPinsChanged().GetPinned(), pinned.GetPinned()) + requireRedactedSummary(in.MetadataUpdates[0].GetPinsChanged().Summary, pinned.Summary) + + // An unpin: the ID as it was, and the summary's latest redacted, or no + // latest when nothing is left pinned. + for _, i := range []int{1, 2} { + unpinned := out.MetadataUpdates[i].GetPinsChanged() + assert.True(t, proto.Equal(in.MetadataUpdates[i].GetPinsChanged().GetUnpinned(), unpinned.GetUnpinned())) + requireRedactedSummary(in.MetadataUpdates[i].GetPinsChanged().Summary, unpinned.Summary) + } + + // A metadata refresh's and a join's metadata: the pin summary's latest is + // redacted beside the last message. + refreshed := out.MetadataUpdates[3].GetFullRefresh().GetMetadata() + assert.Equal(t, "Title", refreshed.Title) + assert.True(t, refreshed.LastMessage.Redacted) + requireRedactedSummary(in.MetadataUpdates[3].GetFullRefresh().GetMetadata().PinSummary, refreshed.PinSummary) + joined := out.RosterUpdates.RosterUpdates[0].GetMemberJoined().GetMetadata() + requireRedactedSummary(in.RosterUpdates.RosterUpdates[0].GetMemberJoined().GetMetadata().PinSummary, joined.PinSummary) + + // A redacted update is its own redaction. + again, err := ChatUpdate(chatID, out) + require.NoError(t, err) + assert.True(t, proto.Equal(out, again)) + + // A pin whose message cannot be redacted fails the update, wherever it + // rides; a pins change of a kind this version does not know is refused. + for _, md := range []*chatpb.MetadataUpdate{ + {Kind: &chatpb.MetadataUpdate_PinsChanged_{PinsChanged: &chatpb.MetadataUpdate_PinsChanged{ + Change: &chatpb.MetadataUpdate_PinsChanged_Pinned{Pinned: &chatpb.Pin{MessageId: messageID, Message: messageFixture(&messagingpb.Content{})}}, + Summary: &chatpb.PinSummary{}, + }}}, + {Kind: &chatpb.MetadataUpdate_PinsChanged_{PinsChanged: &chatpb.MetadataUpdate_PinsChanged{ + Change: &chatpb.MetadataUpdate_PinsChanged_Unpinned{Unpinned: messageID}, + Summary: &chatpb.PinSummary{Latest: &chatpb.Pin{MessageId: messageID, Message: messageFixture(&messagingpb.Content{})}}, + }}}, + {Kind: &chatpb.MetadataUpdate_FullRefresh_{FullRefresh: &chatpb.MetadataUpdate_FullRefresh{ + Metadata: &chatpb.Metadata{ChatId: chatID, PinSummary: &chatpb.PinSummary{Latest: &chatpb.Pin{MessageId: messageID, Message: messageFixture(&messagingpb.Content{})}}}, + }}}, + } { + _, err := ChatUpdate(chatID, &eventpb.ChatUpdate{Chat: chatID, MetadataUpdates: []*chatpb.MetadataUpdate{md}}) + assert.ErrorIs(t, err, ErrUnsupportedContent) + } + _, err = ChatUpdate(chatID, &eventpb.ChatUpdate{Chat: chatID, MetadataUpdates: []*chatpb.MetadataUpdate{{ + Kind: &chatpb.MetadataUpdate_PinsChanged_{PinsChanged: &chatpb.MetadataUpdate_PinsChanged{Summary: &chatpb.PinSummary{}}}, + }}}) + assert.Error(t, err) +}