Changelog
What changes between rim-server releases, for the people who run a server and the people who
write a client. The format is Keep a Changelog and the
versions follow Semantic Versioning; a release is the
tag v<version>, the version every crate in the workspace carries.
How entries are written #
- A pull request that changes what an operator, a user or a client author sees adds its line
under
Unreleasedin the same pull request. Refactors, tests and internal documentation do not. - Each line goes under one heading:
Added,Changed,Deprecated,Removed,FixedorSecurity. It says what changed for the reader, not how it was built. - A change the operator has to act on (a new required setting, a migration that rewrites a
large table, a contract migration, a changed default) starts with Upgrade: and says what
to do.
docs/operations/upgrade.mdis how an upgrade runs. - A change to the GraphQL schema or a spec in
docs/specs/names the type, field or spec. - At release time
Unreleasedbecomes## [<version>] - <YYYY-MM-DD>and a new emptyUnreleasedstarts above it (docs/operations/release-checklist.md).
Unreleased #
Added #
Who may start a DM, in three layers where each only tightens the one above ("Who May Start A DM" in
docs/messaging.md). The operator setsRIM_DM_INBOUND(open,requestsby default,partners,none),RIM_DM_OUTBOUND(allowedby default,partners,none) andRIM_DM_PARTNERS, shown in the newServerInfo.dmInbound,dmOutboundanddmPartners; a workspace's Owner sets a floor for DMs from outside and a default for members' DMs with the newsetWorkspaceDmPolicy(Server.dmOutsideFloor,Server.dmMemberDefault); each person chooses with the newsetMyDmSettingsandsetMyWorkspaceDmSetting, read withmyDmSettings(effectiveOutside,outsideFloorFrom). New enumsDmInbound,DmOutbound,DmOutsideFloor,DmMembers,DmOutside,DmFloorSourceKind. A workspace DM a member takes as a request now goes through their request inbox.docs/operations/closed-company-server.mdis the recipe for a server closed to DMs from outside or open to partners only.GraphQL
Conversation.dmRequestPending: for a DM, true while the other member's DM request for it is pending, so the sender can show that their messages wait for acceptance; null for groups and channels.GraphQL
ServerInfo.signupRequiresKeys: true when new accounts sign up with a key only andsignupWithInviteis refused, so a client offers only the key signup.Invitations to encrypted conversations: an encrypted DM or group may now include members who have no device ready for encryption. They are listed in the new
Conversation.pendingMemberIds, see the conversation but cannot read, send or be notified, and join with the newacceptConversationInviteonce a device is ready, or decline withdeclineConversationInvite. Invitations expire after 30 days, andRIM_LIMIT_PENDING_INVITES_PER_USER(serverInfo.limits.pendingInvitesPerUser, default 50) caps how many one user holds. Before, the server refused such members; it still refuses a creator who is not ready (e2ee-mls-v1.md).Instance moderation in the app:
rim_server operator grant | revoke | listmakes an account an instance operator,me.instanceOperatorsays whether the caller is one, and the queriesinstanceReports,instancePrincipalBlocks,instanceIpBlocksand the mutationsinstanceBlockPrincipal,instanceUnblockPrincipal,instanceResolveReport,instanceBlockIp,instanceUnblockIpdo what theabuse-reports,principalandipcommands do; anyone else getsFORBIDDEN. Their audit lines now carryactor_id.LoginPayload.csrfTokenandSignupWithInvitePayloadGql.csrfToken:login,loginWithKey,signupWithInviteandsignupWithKeyreturn the session's CSRF token, the value of therim_csrfcookie, so a client that cannot read that cookie (a packaged desktop app talking to a server on another host) can send it inX-CSRF-Token.docs/operations/upgrade.md: how to move a server to a new release and back.Release images: every release tag publishes
ghcr.io/rim-platform/rim-server:<version>(also<major>.<minor>,sha-<short sha>andlatest), signed with cosign keyless by the release workflow and with an SPDX SBOM attested to it and attached to the GitHub release.docs/self-hosting.md, "Verify the images", checks the signature.GraphQL
ServerInfo.auditRetentionDays: the days command audit rows are kept before they are exported and deleted, null when they are kept forever. Readable without a session, like the rest ofserverInfo.Request limits, each off unless set:
RIM_RATE_REQUESTS_PER_MIN_PER_IP(requests to/apiroutes per client address, answered 429 withRetry-After),RIM_RATE_MUTATIONS_PER_MIN_PER_SESSION,RIM_RATE_MESSAGES_PER_MIN_PER_USERwithRIM_RATE_MESSAGES_BURST(both answeredRATE_LIMITED),RIM_RATE_WS_CONNECTIONS_PER_USERandRIM_RATE_SUBSCRIPTIONS_PER_CONNECTION(the new GraphQL error codeTOO_MANY_SUBSCRIPTIONS). Refusals are counted inrim_rate_limit_rejections_total, and browsers may readRetry-After.docs/api.md("Limits") describes them.An IP deny list:
rim_server ip block <cidr> --reason <text> [--expires-in-days <1-365>],ip unblock <cidr>andip list. A blocked address or range, IPv4 or IPv6, is answered 403 with{"error":"blocked"}on every route but/healthz, server-to-server routes included. A running server picks up changes within 30 seconds; blocks are audited asip.blockandip.unblockand refusals counted inrim_ip_blocked_requests_total. Adds theip_blocktable.RIM_INSTANCE_NOTICEandRIM_TERMS_URL: a short operator notice and the terms of service URL, served as GraphQLServerInfo.instanceNoticeandServerInfo.termsUrl. With terms set,signupWithInviteandsignupWithKeyrequireacceptedTermsUrl(a new optional field ofSignupWithInviteInputandSignupWithKeyInput) equal to the terms URL and answerVALIDATIONotherwise; the acceptance is stored on the account.Instance limits, each off unless set:
RIM_LIMIT_WORKSPACES_PER_USER,RIM_LIMIT_MEMBERS_PER_WORKSPACE,RIM_LIMIT_CHANNELS_PER_WORKSPACE,RIM_LIMIT_DEVICES_PER_USER,RIM_LIMIT_DM_REQUESTS_PER_DAY,RIM_LIMIT_MACROS_PER_WORKSPACE,RIM_LIMIT_PIPELINES_PER_WORKSPACE,RIM_LIMIT_PLUGINS_PER_WORKSPACE,RIM_LIMIT_INVITES_PER_USER_PER_DAY,RIM_LIMIT_INVITE_MAX_USESandRIM_LIMIT_MESSAGE_BYTES. Creating one more past a cap answersVALIDATIONnaming it. GraphQLServerInfo.limits(typeServerLimits, one nullableIntper cap) reports them; for devices and DM requests it reports the stricter of the cap and the relay quota, which is what applies.docs/api.md("Limits") lists them.Probation for new accounts:
RIM_PROBATION_HOURS(off unless set) keeps an account younger than that from creating a workspace or sending a DM request to a stranger, answeredVALIDATIONnaming the minutes left, andRIM_RATE_PROBATION_MESSAGES_PER_MINgives it a lower message rate. GraphQLServerLimits.probationHoursreports the setting andUser.probationEndsAt, read onme, says when the caller's probation ends.GraphQL
transferWorkspaceOwnership(input: { serverId, userId })hands a workspace to another member, who becomes its one owner while the caller becomes a moderator, anddeleteWorkspace(input: { serverId, confirmName })(answersDeleteWorkspacePayload { deleted }) deletes a workspace with everything in it once its owner types its name.Self-service account deletion: GraphQL
requestAccountDeletion(input: { confirmUsername })(answersAccountDeletionSchedule { scheduledFor }),cancelAccountDeletionandUser.deletionScheduledFor, set onmewhile a deletion is pending. After a grace period (RIM_ACCOUNT_DELETION_GRACE_DAYS, default 14, 0 to 30) an hourly job purges the account and leaves a tombstone ("Deleted account") as the author of its encrypted messages;rim_server account purge [--dry-run]runs the purge on demand. The only owner of a workspace with other members is refused until they transfer or delete it. The tombstone keeps the key identity id, sosignupWithKeywith a deleted account's identity answersVALIDATION. Signups may no longer take usernames starting withdeleted-. Addsuser.deletion_requested_at,deletion_scheduled_foranddeleted_at.The invite chain and the complete ban. Every account records the invite it signed up with and that invite's creator (new nullable
user.created_by_invite_idanduser.invited_by_user_id).rim_server principal blocknow also revokes the account's invites that have uses left, blocks its email (the newblocked_emailtable: a signup with it answersVALIDATION"this email cannot sign up here", a password loginFORBIDDEN), records the reason as a resolved abuse report, and lists the accounts its invites created;--cascadeblocks those too, recursively, in the same transaction.principal unblockalso lifts the email block; sessions and invites stay ended. The newprincipal show <principal-id>prints the account, its inviter, its invite tree, its invites, its open reports and its block.RIM_RESERVED_NAMES: comma-separated usernames and display names thatsignupWithInvite,signupWithKeyandupdateMyProfilerefuse withVALIDATION"this name is reserved", compared after trimming, Unicode NFKC normalisation and lowercasing, and also followed by-,_,.or a space (rimreservesrim-admin, notrimmer). An account whose own username is reserved may still take a reserved display name. An empty entry stops the server at boot.Reaction notifications: a reaction on someone's message tells its author with the new
NotificationReason.REACTION, one entry per message that each later reaction re-surfaces unread.Notification.reactionEmoji,Notification.reactedBy, the settingNotificationSettings.reactions/UpdateNotificationSettingsInput.reactions(default on), andreaction_emoji/reaction_byon theNotificationDueevent.RIM_DEFAULT_WORKSPACE: the id of a workspace on this server that every new account joins as a Member, with its public channels, whichever way it signs up, so people land where others already are. GraphQLServerInfo.defaultWorkspacenames it, null when unset. A workspace that does not exist here, or the direct space, stops the server at boot.
Changed #
A confirmation of a command now lasts 60 seconds, at most 300, instead of five, so a person has time to read the confirmation dialog and answer it before it lapses. A proof is still good once, for the same caller, command, arguments and context ("Confirmation gate (H1)" in
docs/command_model.md).Workspace members are no longer always delivered: a member's DM follows the recipient's choice for that workspace,
DIRECTunless the workspace or the person setREQUESTS; Owners and Moderators are always delivered. With the defaults everything arrives as before.Creating a DM records the other person as the creator's contact (
contacts), so their DM back is never a request.With
RIM_DM_INBOUND=partnersornone,GET /s2s/v1/principals/<id>answers404and key package claims answer403to servers that are not partners, except a server hosting a workspace the person subscribed to (server-to-server-v1.md, "The request inbox").Upgrade: password signups are refused by default.
RIM_SIGNUP_REQUIRE_KEYS(defaulttrue) makessignupWithInviteanswerFORBIDDENin every registration mode, so new accounts sign up withsignupWithKey; existing password accounts still sign in withlogin. Set it tofalseindeploy/.envwhile your users run a client that cannot sign up with a key.signupWithKeyaccepts an instance invite bound to an email address and gives the new account that address, refusing it like a password signup does when another account has it (EMAIL_TAKEN) or an operator blocked it (VALIDATION); before, it refused such an invite. A password signup with another address now answers "this invite admits only the email address it was issued for".enrollIdentityKeyends every other session of the account in the same transaction and closes their WebSocket connections; before, other password sessions stayed valid until they expired or were signed out.key-identity-v1.mdanddevice-linking-v1.md: the web client may create and hold an identity, sign up with a key and link other devices; a browser-held identity is never backed up, the recovery kit is shown after signup or enrollment and its entropy is kept until the person confirms they saved it.Upgrade:
deploy/compose.ymlruns the published imagesghcr.io/rim-platform/rim-serverandghcr.io/rim-platform/rim-webatRIM_VERSION(defaultlatest) instead of building them. SetRIM_VERSIONindeploy/.envto the release you run, and runpullthenup -dwhere you ranup -d --build. Operators who build locally add-f deploy/compose.dev.ymlto every compose command;RIM_SERVER_IMAGEandRIM_WEB_IMAGEstill name other images.Upgrade: a GraphQL batch holds at most 20 requests; a larger one is answered 400 with
{"error":"batch_too_large"}and none of it runs.RIM_API_MAX_BATCHchanges the cap.With
link_previewsinRIM_FEATURES_DISABLED, sending or replying to a message no longer starts a link preview fetch at all, so the server makes no outbound request for the links people post.The packaged desktop app's origins (
tauri://localhost,http://tauri.localhostandhttps://tauri.localhost) are accepted by every server withoutRIM_CORS_ALLOWED_ORIGIN.GraphQL
commandsandcommandDiscoverlist only the commands the caller may run. A command the server runs needs a context workspace the caller is a member of, a role there that may run it and its capabilities: a non-member, or a call without a workspace, sees only the commands the client runs itself, and the moderation and owner commands are listed only to the roles that run them. Naming the direct space makes the caller a guest of it.Workspace-only features answer
VALIDATION("not available in the direct space") in the direct space instead of an empty result, for reads and writes alike: custom emojis, workspace plugins and plugin bundles (POST /api/plugin-bundles/{server_id}answers 400), keybinds scoped to a workspace, pipelines, workspace state and channels. The caller's own keybinds, asked without a workspace id, still answer.Typing is announced in direct conversations only.
startTypingin a channel or a group answersfalseand emits noTypingStarted; it also answersfalseon a repeat inside the throttle window, andtrueonly when it emitted the event.The dependency audit in CI and in the release gates is
cargo audit --deny unsound: an unsoundness advisory fails it like a vulnerability. Each advisory it accepts is listed in.cargo/audit.tomlwith why it does not apply and when the entry comes out.
Fixed #
- The
TypingStartedevent ofworkspaceEventscarries the typing user'susernameanddisplay_name, so a client can say who is typing. - A local account's first use of the direct space, any operation that needs membership there, joins it as a guest instead of being refused.
executeCommandrefuses a builtin whose required role the caller does not hold in its workspace, withFORBIDDENnaming the command, before the service behind it runs; user command steps and pipe stages are checked the same way.- On a server with terms of service, every signup path refuses an account that did not accept
them, not only the
signupWithInviteandsignupWithKeyresolvers. addContactrefuses the caller's own principal, andcreateConversationwithremoteMembersnaming only the caller answers "cannot create DM with yourself" instead of the group message.
Security #
rustls0.23.46: the TLS library behind outgoing HTTPS requests picks up the fixes for RUSTSEC-2026-0346, -0347, -0348 and -0349 (TLS secrets inDebugoutput, buffered data after an ECH rejection, certificate decompression memory, key-exchange downgrade).- A member removed from a workspace (kicked, banned) or from a conversation (left, removed) loses their notifications for it, and the email digest leaves out anything its recipient can no longer read. Before, a kicked member's pending notification was still emailed in the next digest, carrying the message's current text, edits made after the kick included.
- A mutation whose protected field sits in nested fragments, named or inline, needs the CSRF
header. Before, a request that showed only an exempt field such as
logoutat its top level skipped the check while a fragment two levels down ranupdateMyProfile. The server reads the operationoperationNameselects through fragments at any depth, and requires the header whenever it cannot account for every root field. The refusal of account mutations for people from another server reads operations the same way. RIM_RATE_MUTATIONS_PER_MIN_PER_SESSIONcharges every mutation field a request runs: fields in nested fragments count, and a fragment spread twice counts twice. Before, fields more than one fragment deep were not charged./api/wsruns subscriptions only. A query or a mutation sent on it answers oneVALIDATIONerror, "only subscriptions over the WebSocket", and runs nothing. Before, a mutation sent there ran without the CSRF check, the mutation budget or the refusal of account mutations for people from another server. Clients send queries and mutations toPOST /api.- Link previews stop reading a page at 1 MiB as it arrives, where a page sent without a length
used to be read whole before the cap applied. At most
RIM_LINK_PREVIEW_CONCURRENCYfetches (default 8, 1 to 64) run at once; a fetch that waits five seconds for a slot is dropped. - Link previews and server-to-server requests share one address policy that also refuses
reserved IPv4 (
240.0.0.0/4), benchmarking and protocol-assignment ranges, and IPv6 addresses that embed a private IPv4 address through NAT64 (64:ff9b::/96) or 6to4 (2002::/16). Server-to-server requests now ignore proxy environment variables, as link previews did.docs/specs/server-to-server-v1.mdlists the refused ranges. - Password hashing and verification (signups, password logins, password resets) run off the
request workers, at most
RIM_PASSWORD_HASH_CONCURRENCYat once (default: the number of CPUs, 1 to 64), so a burst of logins no longer stalls other requests. A request that waits over 10 seconds for a turn answersUNAVAILABLE. - A WebSocket connection on
/api/wsno longer outlives its session. When the session is signed out (logout,logoutOtherSessions), reset, revoked with its device, ended by an identity recovery, a requested account deletion, a block or the purge, or reaches its expiry, the server ends the connection's streams and closes it with code4401("session ended"). Every operation on a connection checks the session first. A session ended in another server process or from the command line closes its connections withinRIM_WS_SESSION_RECHECK_SECS(default 30, 0 turns the recheck off).docs/api.md("Subscriptions") describes it; a client that sees4401signs in again before reconnecting. - A message on
/api/wsis at most 256 KiB, whole or in fragments; a larger one closes the connection with code1009("message too large"), refused at the frame header where it can be. Before, fragments were reassembled without a limit and queued for the server unbounded. - Pipelines react only to events their creator may see: a message, edit, deletion or reaction
in a private channel, DM or group the creator is not in (for a guest, any conversation they
are not in) no longer fires their pipeline or counts towards its cooldown or suspension, and a
creator who has left the workspace has no pipeline firing. Only the creator is told when a
pipeline fires or is suspended, and
PipelineGql.lastFiredAtreads null for everyone else. Before, any member could build a pipeline with a text or author filter and learn from its firing when a private conversation they were not in received a matching message.