Repository navigation
Folders carry permissions their channels follow (GRYT-1306) - #207
Merged
Merged
Conversation
services/channelPermissions.ts held a raw NUL byte in UNREADABLE, so git and GitHub treated the whole file as binary and showed no diff for it. The escape is the same string. Co-Authored-By: Claude Opus 5 <[email protected]>
A folder has a permission scope now, the same as a channel: Everyone, a template, or rules of its own, set with server:folders:scope:set. A channel in a folder follows it until somebody picks the channel's own, Everyone included, and server:channels:scope:follow hands it back. sidebar_items.permission_scope_id holds the folder's scope, and channels.follows_folder says whether a channel with none of its own takes it. The migration adds both and writes nothing else. A scope of its own beats the flag, so every channel answers as it did before. Folders start with no scope, so a channel with none still gets the server-wide answer. resolveChannelScopes is the one place that works out which scope decides a channel. The permission cache is built from it, so reading, sending, speaking, joining voice, typing, the member list, webhooks, reports and file access all use the folder's scope where a channel follows one. server:details and server:sidebar:list leave out a folder, name and all, for anyone who can't see a channel in it. manage_channels still gets every folder. server:sidebar:list used to hand any member every row, hidden channels' ids included, and is filtered the same way now. A channel that follows a folder with a scope keeps that scope as its own when it ends up in no folder: dragged to the top level, its row deleted, or the folder deleted. A folder's own rules are copied. Moving a following channel into a folder with a different scope needs manage_channels as well as manage_sidebar, since that changes who can see it. voice:room:request refuses a room whose scope denies join_voice, which only the client's canJoin knew about before. server:channels:upsert takes parentItemId, so a new channel's first row is already in its folder. A sidebar write removes other rows for the same channel in the same step instead of a few awaits later. Deleting a template puts the channels and folders on it back to Everyone. folderPermissions.test.ts drives the handlers as a member, an admin and somebody with only manage_sidebar, and folderScopeMigration.test.ts upgrades a database from the previous schema. Co-Authored-By: Claude Opus 5 <[email protected]>
This was referenced Sep 21, 2026
sivert-io
added a commit
to Gryt-chat/docs
that referenced
this pull request
Sep 21, 2026
server:channels:scope:get, server:channels:scope:set, server:channels:scope and the four server:permissions template events were never on the socket reference. Listing them lifts its coverage to 155 of 212. That leaves room for the four folder permission events Gryt-chat/server#207 adds. They can only go on the page once the server has them (GRYT-1349). Co-Authored-By: Claude Opus 5 <[email protected]>
sivert-io
added a commit
to Gryt-chat/docs
that referenced
this pull request
Sep 21, 2026
server:channels:scope:get, server:channels:scope:set, server:channels:scope and the four server:permissions template events were never on the socket reference. Listing them lifts its coverage to 155 of 212. That leaves room for the four folder permission events Gryt-chat/server#207 adds. They can only go on the page once the server has them (GRYT-1349). Co-authored-by: Claude Opus 5 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Task: GRYT-1306. Client half: Gryt-chat/client#632. Phone: Gryt-chat/mobile#234.
This touches
src/db/**, so it waits for Sivert to read it. Don't merge it on green.Read these first
resolveChannelScopesinsrc/db/sqlite/channelScopes.ts. It's the whole rule, in one place: a channel that sits in a folder and has no scope of its own takes the folder's, unless somebody picked Everyone for it. Everything that asks what a channel allows gets its answer from here, through the permission cache inservices/channelPermissions.ts.connection.ts. It addssidebar_items.permission_scope_idandchannels.follows_folder(default 1), and writes nothing else. A scope of its own beatsfollows_folder, so every existing channel answers as it did. Folders start with no scope, so a channel with none still gets the server-wide answer, and a channel with one keeps it.folderScopeMigration.test.tsbuilds the old schema, fills it and upgrades it.keepScopesOfChannelsLeavingFoldersandwriteKeepingFolderScopesinchannels.tsandchannelScopes.ts. A channel that follows a folder with a scope and ends up in no folder keeps that scope as its own. That covers dragging it to the top level, deleting its row and deleting the folder. A folder's own rules are copied, since they go with the folder. It all runs in one transaction with the sidebar write.upsertServerSidebarItemnow removes any other row for the same channel in the same step. The handler used to do that a few awaits later, and the channel could be sent twice in between.services/channelPermissions.tsshows as a binary file in the combined diff. Main has a raw NUL byte in it, inUNREADABLE. The first commit swaps that for\u0000, which is the same string, and the second commit's diff of the file reads normally.What changes behaviour
server:detailsandserver:sidebar:listleave out a folder, name included, for anyone who can't see a channel in it. Anyone with manage_channels still gets every folder, empty ones too. They can list every channel anyway, and a new folder is empty until something goes in.server:sidebar:listused to send every row to any member, hidden channels' ids included. It's filtered the same way now.voice:room:requestrefuses a room whose scope denies join_voice. Until now only the client knew, throughcanJoin, and the grant ignored it.server:channels:upserttakesparentItemIdfor a new channel, so its first row goes straight into the folder. Without it the channel went out to everybody at the top level for a moment before the client's own row moved it.New events, all manage_channels:
server:folders:scope:get,server:folders:scope:set, andserver:channels:scope:follow, which hands a channel back to its folder.server:channels:scope:getnow answers with whatever applies, which is the folder's scope while the channel follows it. It also sendsfollowsFolderandfolder.server:detailshasserver_info.folder_permissions: trueso a client knows it can offer folder settings.Decisions to check
Not in this
The permission matrix offers thirteen permissions, but the server only checks read_messages, send_messages, speak and join_voice per channel. The rest are still server-wide only. That was true before this and is GRYT-1346.
The reference check
It fails on this PR at 69% until Gryt-chat/docs#122 merges. That PR documents seven channel permission events the server already had, and that lifts this one to 72%. The four events added here can't go on the page before the server has them. Documenting them is GRYT-1349.
Tests
folderPermissions.test.tsruns the real handlers as a member, an admin and somebody with manage_sidebar only. It sets a folder's permissions throughserver:folders:scope:setand checks the member is turned out of its voice room. After that, the folder's name and its channels' ids mustn't show up anywhere in the member'sserver:details,server:sidebar:listormembers:list.chat:fetchandchat:sendanswer exactly as for a channel that doesn't exist, andvoice:room:requestis refused while the open room is granted. It also covers a channel with its own Everyone staying visible and bringing its folder back, following again, moves in and out, the manage_channels guard, a new channel made in the folder, a deleted folder, a folder on a template and the count on it, and join_voice denied alone.channelVisibilityLeaks.test.tsgetsserver:sidebar:listas a path, andpermissionGates.test.tsgets the three new events.Twelve mutations each fail them: the folder filter in either place, the cache reading a channel's own scope, dropping the keep-on-leave step, the join_voice check, the move guard,
parentItemIdon create, the one-row rule, own-beats-follow, Everyone-as-own, the eviction, and keeping empty folders for everybody.Verified
yarn test(1420),yarn test:examples,yarn build,npx eslint .(the five existing warnings),check-comment-lengthandcheck-selfhosted-config. I also built an image from this branch and ran the client's whole e2e suite against it withGRYT_E2E_SERVER_IMAGE, on Gryt-chat/client#632: 44 passed. That includes the new folder test, which drives an owner and a guest in Chromium and reads the guest's DOM and socket frames, and the create-channel tests from GRYT-1340, which now go throughparentItemIdand the one-row rule.Release order
This one first. The client works against an older server, and its folder test skips itself until
server:latesthas this.🤖 Generated with Claude Code