Skip to content

feat(realtime): let extensions authorize their own private channels - #5220

Open
ernestdefoe wants to merge 1 commit into
flarum:2.xfrom
ernestdefoe:feat/realtime-private-channel-extender
Open

ernestdefoe wants to merge 1 commit into
flarum:2.xfrom
ernestdefoe:feat/realtime-private-channel-extender

Conversation

@ernestdefoe

@ernestdefoe ernestdefoe commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Changes proposed in this pull request:

This adds Extend\Realtime::authorizePrivateChannel($subject, $callback), so an extension can open a private channel of its own, private-{subject}={id}, and decide who may subscribe to it.

Today AuthController only authorizes a private channel when it has a method named after the subject (user, typing, privateMessageTyping and so on), so a channel defined by an extension always gets a 403. authorizePresenceChannel() can only narrow a presence channel realtime already defines. So an extension that needs a channel scoped to one model (a post being edited together, a call, a game) has no channel to use. Every message has to go through PHP and then out to each member's private-user={id}, which costs a request per message and rules out client- events between peers.

(new Extend\Conditional())
    ->whenExtensionEnabled('flarum-realtime', fn () => [
        (new \Flarum\Realtime\Extend\Realtime())
            ->authorizePrivateChannel('postEdit', function (User $actor, int $id) {
                $post = Post::whereVisibleTo($actor)->find($id);

                return $post !== null && $actor->can('edit', $post);
            }),
    ]),

Once subscribed, the existing relay already handles client- events on any private channel (Message::isAuthorizedClientEvent()), and the server can push with Pusher::trigger(). The only missing piece was authorization.

Reviewers should focus on:

  • Guards fail closed. Unlike presence guards, which only narrow, these grant access to a channel realtime knows nothing about. So a subscription needs at least one guard for the subject, and every guard must return exactly true (not just truthy).
  • Realtime's own subjects can't be redefined. AuthController still checks its own methods first, and the extender throws if a subject matches one of them. method_exists() ignores case, so that covers User as well as user, and also the controller's other methods (handle, online). The extender also rejects subjects the channel name format can't carry (letters only).
  • The callback receives the actor, who may be a guest, and the id as an int. Whether guests are allowed is the callback's decision, as it is for the built-in typing channel.
  • PrivateChannelAuthorizer mirrors PresenceChannelAuthorizer: a singleton filled through afterResolving.

Screenshot
N/A

Necessity

  • Has the problem that is being solved here been clearly explained?
  • If applicable, have various options for solving this problem been considered? (I also considered routing through private-user={id} from PHP. That works, but it costs a PHP request per message. Overriding AuthController from an extension isn't an option either, because two extensions would collide.)
  • For core PRs, does this need to be in core, or could it be in an extension? (It has to be in realtime: only its auth endpoint can sign a channel subscription.)
  • Are we willing to maintain this for years / potentially forever?

Confirmed

  • Frontend changes: tested on a local Flarum installation. (No frontend changes.)
  • Frontend changes: tests are green (run yarn test in js/). (No frontend changes.)
  • Frontend changes: tests have been added, or are not appropriate here. (No frontend changes.)
  • Backend changes: tests are green (run composer test). Unit 93/93 and integration 66/66 (1 skipped) on SQLite; PHPStan clean on the changed files.
  • Backend changes: tests have been added, or are not appropriate here. (PrivateChannelAuthorizerTest, new cases in RealtimeExtenderTest, and PrivateChannelExtenderAuthTest through /api/websocket/auth.)
  • Where applicable, changes are suitable for all supported database drivers (MySQL, MariaDB, PostgreSQL, SQLite). (No database changes.)
  • Core developer confirmed locally this works as intended.
  • The description above is written by me and describes what this pull request actually does.

🤖 Generated with Claude Code

Add `Extend\Realtime::authorizePrivateChannel($subject, $callback)`, so an
extension can open `private-{subject}={id}` and decide who may subscribe.
Until now the auth endpoint only accepted channels with a method on
AuthController, so an extension's channel always got a 403, and anything
scoped to one model had to go through PHP and `private-user={id}`.

Guards fail closed: access needs at least one guard and every guard must
return exactly true. Realtime's own subjects can't be registered, so an
extension can't widen access to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ernestdefoe
ernestdefoe requested a review from a team as a code owner October 16, 2026 18:13
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