Skip to content

[2.x] feat(realtime): let extensions register their own websocket channels - #4913

Open
ekumanov wants to merge 1 commit into
flarum:2.xfrom
ekumanov:feat/realtime-extension-channels
Open

[2.x] feat(realtime): let extensions register their own websocket channels#4913
ekumanov wants to merge 1 commit into
flarum:2.xfrom
ekumanov:feat/realtime-extension-channels

Conversation

@ekumanov

@ekumanov ekumanov commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Part 1 of #4912. Fixes the closed-set half; the client-event relay hook is
deferred to a second PR, as discussed there.

The problem

AuthController resolves a channel's subject to a method on itself:

// src/Websocket/Api/AuthController.php:55
if (preg_match('~^private-(?<subject>[a-zA-Z]+)=(?<id>[0-9]+)$~', $channel, $m)) {
    if (method_exists($this, $m['subject']) && call_user_func([$this, $m['subject']], $m['id'])) {

so the only channels that can exist are the ones it defines.
Extend\Realtime::authorizePresenceChannel() adds a guard to an existing channel
rather than defining one, so there is no way for an extension to obtain a channel
of its own. It has to put its data on one of realtime's instead and inherit that
channel's audience — for private-typing={id} that is everyone who can see the
discussion, guests included, whatever permission the extension checks when it
renders.

Changes

A ChannelRegistry, populated by the extender.

(new \Flarum\Realtime\Extend\Realtime())
    ->privateChannel('acme-readers', fn (User $actor, int $id) => …)      // → bool
    ->presenceChannel('acme-readers', fn (User $actor, ?int $id) => …);   // → member data, or false

Authorization stays where it already was: an ordinary HTTP request, once per
subscription, with a real actor and the full permission machinery. The websocket
server still does no permission work per event.

Realtime registers its own channels the same way, from extend.php, instead
of keeping a private path beside the public one. The subject authorizers move to
Websocket\Api\DefaultChannels with their explanations intact; AuthController
is left routing.

Channel-name patterns. A subject may now contain hyphens, which retires the
private-index-typing-tag={id} special case that existed because [a-zA-Z]+
could not match it. A presence channel may now carry ={id}: presence channels
were forum-wide by construction, which is why a roster scoped to one object
couldn't use one.

Guest handling is unchanged, and now stated. Private channels have never
required a session, so the callback decides; presence channels key a member list
by user id, so guests are refused before it runs.

Duplicate registration throws, rather than silently giving one extension's
channel another extension's permissions.

Drive-by fix

Because method_exists() matched any method, not just the authorizers, every
method name was a channel name. private-handle=1 and private-online=1 called
handle('1') / online('1'), died on the argument type and returned 500 to an
unauthenticated caller
. No authorization was bypassed — authorizeChannel() is
never reached — but unregistered subjects are now simply refused, with a
regression test.

Compatibility

  • No behaviour change for any channel that works today. Built-in channels
    authorize identically (byte-identical signatures before/after against a running
    forum).
  • authorizePresenceChannel() is untouched and keeps working; guards stack,
    definitions don't. The docblock now says which to reach for.
  • AuthController's constructor drops the unused Push\Payload\Generator and
    gains the registry. It's resolved from the container by the route.
  • No JS, no migration.

Reviewers may want to look at

  • Whether realtime should dogfood the extender or keep its own channels on a
    separate path. Dogfooding is what makes it one code path, and it's most of the
    churn in AuthController — happy to reduce it to an explicit built-in
    allowlist plus a registry for extensions if you'd rather have a smaller diff.
  • Duplicate registration throwing. Loud, but it happens at boot, so two
    extensions choosing the same subject would take the forum down until one is
    disabled. The alternative is logging and keeping the first.
  • private channels admitting guests. Preserved from today's behaviour, but
    it is a sharp edge for anyone registering one, so it's documented on both the
    extender method and the registry.

Tests

  • tests/unit/Websocket/ChannelRegistryTest.php — registration and refusal
    semantics: unregistered subjects, callbacks returning truthy-but-not-true,
    optional presence id, duplicate registration, private/presence subjects being
    separate namespaces.
  • tests/unit/Extend/RealtimeExtenderTest.php — the two new extender methods
    reach the registry, and stay chainable with the rest.
  • tests/integration/api/ExtensionChannelAuthTest.php — drives
    /api/websocket/auth: an extension channel authorized by its own permission
    and refused to someone who can see the discussion but lacks it; guests excluded
    from an extension channel while still admitted to private-typing; a presence
    channel scoped with ={id}; unregistered subjects; the controller-method
    regression; built-in channels still authorizing.

extensions/realtime unit 57 passed, integration 53 passed (1 pre-existing
skip), PHPStan level 6 clean. Run on PHP 8.4 / MySQL 8.0.


🤖 Generated with Claude Code

Channel names were a closed set. `AuthController` matched the subject out of the
name and looked for a method of that name on itself, so the only channels that
could exist were the ones it defined; `Extend\Realtime::authorizePresenceChannel()`
adds a *guard* to a channel realtime already defines, which is not the same thing.
An extension therefore had no way to obtain a channel of its own, and had to carry
its data on one of realtime's — inheriting that channel's audience. For the
discussion typing channel that audience is everyone who can see the discussion,
guests included, so anything an extension puts there is disclosed to all of them
no matter what permission it checks when rendering.

Channels come from a `ChannelRegistry` now, which the extender populates:

    (new Flarum\Realtime\Extend\Realtime())
        ->privateChannel('acme-readers', fn (User $actor, int $id) => ...)
        ->presenceChannel('acme-readers', fn (User $actor, ?int $id) => ...)

Authorization stays where it already was — an ordinary request, once per
subscription, with a real actor and the full permission machinery — so the
websocket server still does no permission work per event.

Realtime registers its own channels the same way, from `extend.php`, rather than
keeping a private path beside the public one. The subject authorizers move to
`Websocket\Api\DefaultChannels` with their explanations intact, and
`AuthController` is left doing only what its name says.

Two changes to the channel-name patterns come with it:

  - a subject may contain hyphens, so `private-index-typing-tag={id}` stops
    needing the special case it had (`[a-zA-Z]+` could not match it);
  - a presence channel may carry `={id}`. Presence channels were forum-wide by
    construction, which is why a per-object roster — the case that motivated
    this — could not use one, and had to be rebuilt out of client events on
    somebody else's channel.

Guest handling is unchanged and now explicit: private channels have never
required a session, so the callback decides; presence channels publish a member
list keyed by user id, so guests are refused before the callback runs.

Resolving the subject by `method_exists()` also meant every method on the
controller was reachable as a channel name. `private-handle=1` and
`private-online=1` called `handle('1')` and `online('1')`, died on the argument
type, and returned 500 to an unauthenticated caller. Unregistered subjects are
now simply refused, and a regression test covers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ekumanov
ekumanov requested a review from a team as a code owner August 8, 2026 13:11
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