Skip to content

Folders carry permissions their channels follow (GRYT-1306) - #207

Merged
sivert-io merged 2 commits into
mainfrom
claude/GRYT-1306-folder-permissions
Sep 21, 2026
Merged

sivert-io merged 2 commits into
mainfrom
claude/GRYT-1306-folder-permissions

Conversation

@sivert-io

@sivert-io sivert-io commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

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

  1. resolveChannelScopes in src/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 in services/channelPermissions.ts.
  2. The migration in connection.ts. It adds sidebar_items.permission_scope_id and channels.follows_folder (default 1), and writes nothing else. A scope of its own beats follows_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.ts builds the old schema, fills it and upgrades it.
  3. keepScopesOfChannelsLeavingFolders and writeKeepingFolderScopes in channels.ts and channelScopes.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.
  4. upsertServerSidebarItem now 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.ts shows as a binary file in the combined diff. Main has a raw NUL byte in it, in UNREADABLE. 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:details and server:sidebar:list leave 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:list used to send every row to any member, hidden channels' ids included. It's filtered the same way now.
  • voice:room:request refuses a room whose scope denies join_voice. Until now only the client knew, through canJoin, and the grant ignored it.
  • Moving a channel that follows its folder into a folder with a different scope needs manage_channels as well as manage_sidebar, since it changes who can see the channel. Without it, somebody who can only arrange the sidebar could drag a hidden channel into an open folder.
  • Deleting a template puts the channels and folders on it back to Everyone, as the phone's warning already says. A channel that was on it counts as having its own Everyone afterwards.
  • server:channels:upsert takes parentItemId for 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, and server:channels:scope:follow, which hands a channel back to its folder. server:channels:scope:get now answers with whatever applies, which is the folder's scope while the channel follows it. It also sends followsFolder and folder. server:details has server_info.folder_permissions: true so a client knows it can offer folder settings.

Decisions to check

  • Leaving a folder keeps its permissions rather than opening the channel. Moving straight into another folder follows that one, the way the task asks.
  • Picking Everyone for a channel counts as its own, so it stays open when its folder closes. That's the only way to leave one channel open in a hidden folder.
  • Rolling back loses folder permissions. An older build ignores both columns, so a channel following a folder goes back to its own scope, and that's Everyone for most. Nothing is deleted, so the next upgrade brings them back.

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.ts runs the real handlers as a member, an admin and somebody with manage_sidebar only. It sets a folder's permissions through server:folders:scope:set and 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's server:details, server:sidebar:list or members:list. chat:fetch and chat:send answer exactly as for a channel that doesn't exist, and voice:room:request is 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.ts gets server:sidebar:list as a path, and permissionGates.test.ts gets 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, parentItemId on 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-length and check-selfhosted-config. I also built an image from this branch and ran the client's whole e2e suite against it with GRYT_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 through parentItemId and the one-row rule.

Release order

This one first. The client works against an older server, and its folder test skips itself until server:latest has this.

🤖 Generated with Claude Code

sivert-io and others added 2 commits September 21, 2026 12:42
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]>
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]>
@sivert-io
sivert-io merged commit a49b2c4 into main Sep 21, 2026
6 of 7 checks passed
@sivert-io
sivert-io deleted the claude/GRYT-1306-folder-permissions branch September 21, 2026 11:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant