From 37042932492ce8880ede815d5d714b758eaf0a2c Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Mon, 13 Jul 2026 19:45:05 +0100 Subject: [PATCH 1/2] Support the MSCxxxx recipient_permission push rule condition Evaluates org.matrix.mscxxxx.recipient_permission (stable: recipient_permission): matches iff we (the user the rules are evaluated for) have a power level >= that required for the power-levels action named by `key`. Server-side (synapse) this drives the new default .org.matrix.mscxxxx.rule.knock override rule so users who can accept a knock are pushed; client-side support is needed because Element Web re-evaluates server-supplied rules locally via PushProcessor, which fails rules containing unknown condition kinds. --- spec/unit/pushprocessor.spec.ts | 95 +++++++++++++++++++++++++++++++++ src/@types/PushRules.ts | 11 ++++ src/pushprocessor.ts | 39 ++++++++++++++ 3 files changed, 145 insertions(+) diff --git a/spec/unit/pushprocessor.spec.ts b/spec/unit/pushprocessor.spec.ts index ee30eb9a65d..b266f43bd4a 100644 --- a/spec/unit/pushprocessor.spec.ts +++ b/spec/unit/pushprocessor.spec.ts @@ -1049,3 +1049,98 @@ describe("getPushRuleGlobRegex", () => { expect(input.split(regex)).toEqual(["Foo ", "@room", " Bar"]); }); }); + +describe("recipient_permission condition (MSCxxxx knock push rule)", () => { + const roomId = "!knockroom:server"; + const adminId = "@admin:server"; + const knockerId = "@knocker:server"; + + const knockRule: IPushRule = { + rule_id: ".org.matrix.mscxxxx.rule.knock", + default: true, + enabled: true, + conditions: [ + { kind: ConditionKind.EventPropertyIs, key: "type", value: "m.room.member" }, + { kind: ConditionKind.EventPropertyIs, key: "content.membership", value: "knock" }, + { kind: ConditionKind.RecipientPermissionPrefix, key: "invite" }, + ], + actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Sound, value: "default" }], + }; + + const makeClient = (plContent: IContent): MatrixClient => + ({ + getRoom: () => ({ + currentState: { + getStateEvents: (type: string, stateKey: string) => + type === EventType.RoomPowerLevels && stateKey === "" ? { getContent: () => plContent } : null, + getMember: () => null, + getJoinedMemberCount: () => 2, + members: {}, + }, + }), + ...mockClientMethodsUser(adminId), + supportsIntentionalMentions: () => true, + pushRules: { + device: {}, + global: { + override: [ + knockRule, + { + rule_id: ".m.rule.member_event", + default: true, + enabled: true, + conditions: [{ kind: ConditionKind.EventPropertyIs, key: "type", value: "m.room.member" }], + actions: [], + }, + ], + }, + }, + }) as unknown as MatrixClient; + + const mkKnock = (): MatrixEvent => + utils.mkEvent({ + type: "m.room.member", + room: roomId, + user: knockerId, + skey: knockerId, + event: true, + content: { membership: "knock" }, + }); + + const actionsFor = (plContent: IContent): IActionsObject => { + const pushProcessor = new PushProcessor(makeClient(plContent)); + return pushProcessor.actionsForEvent(mkKnock()); + }; + + it("notifies a user whose power level allows them to invite", () => { + const actions = actionsFor({ invite: 50, users: { [adminId]: 100 } }); + expect(actions.notify).toBeTruthy(); + }); + + it("does not notify a user below the required invite level", () => { + const actions = actionsFor({ invite: 50, users: { [adminId]: 0 } }); + expect(actions?.notify).toBeFalsy(); + }); + + it("uses the spec default invite level (0) when absent", () => { + // invite defaults to 0, users_default defaults to 0 -> everyone can invite + const actions = actionsFor({ users: {} }); + expect(actions.notify).toBeTruthy(); + }); + + it("respects users_default for the recipient's level", () => { + const actions = actionsFor({ invite: 25, users_default: 30 }); + expect(actions.notify).toBeTruthy(); + }); + + it("does not match an unknown permission key", () => { + const pushProcessor = new PushProcessor(makeClient({ invite: 0, frobnicate: 0, users: { [adminId]: 100 } })); + const ruleWithBadKey: IPushRule = { + ...knockRule, + conditions: [{ kind: ConditionKind.RecipientPermissionPrefix, key: "frobnicate" }], + }; + expect( + pushProcessor.ruleMatchesEvent({ ...ruleWithBadKey, rule_id: "test", kind: "override" } as any, mkKnock()), + ).toBe(false); + }); +}); diff --git a/src/@types/PushRules.ts b/src/@types/PushRules.ts index 3f064f7b62c..3348ee4ff38 100644 --- a/src/@types/PushRules.ts +++ b/src/@types/PushRules.ts @@ -64,6 +64,8 @@ export enum ConditionKind { ContainsDisplayName = "contains_display_name", RoomMemberCount = "room_member_count", SenderNotificationPermission = "sender_notification_permission", + RecipientPermission = "recipient_permission", + RecipientPermissionPrefix = "org.matrix.mscxxxx.recipient_permission", CallStarted = "call_started", CallStartedPrefix = "org.matrix.msc3914.call_started", } @@ -103,6 +105,12 @@ export interface ISenderNotificationPermissionCondition extends IPushRuleConditi key: string; } +export interface IRecipientPermissionCondition extends IPushRuleCondition< + ConditionKind.RecipientPermission | ConditionKind.RecipientPermissionPrefix +> { + key: string; +} + export interface ICallStartedCondition extends IPushRuleCondition { // no additional fields } @@ -120,6 +128,7 @@ export type PushRuleCondition = | IContainsDisplayNameCondition | IRoomMemberCountCondition | ISenderNotificationPermissionCondition + | IRecipientPermissionCondition | ICallStartedCondition | ICallStartedPrefixCondition; @@ -143,6 +152,8 @@ export enum RuleId { Message = ".m.rule.message", EncryptedMessage = ".m.rule.encrypted", InviteToSelf = ".m.rule.invite_for_me", + Knock = ".m.rule.knock", + KnockUnstable = ".org.matrix.mscxxxx.rule.knock", MemberEvent = ".m.rule.member_event", IncomingCall = ".m.rule.call", SuppressNotices = ".m.rule.suppress_notices", diff --git a/src/pushprocessor.ts b/src/pushprocessor.ts index 20449774028..42ce670f88f 100644 --- a/src/pushprocessor.ts +++ b/src/pushprocessor.ts @@ -29,6 +29,7 @@ import { type IEventPropertyIsCondition, type IPushRule, type IPushRules, + type IRecipientPermissionCondition, type IRoomMemberCountCondition, type ISenderNotificationPermissionCondition, type PushRuleAction, @@ -479,6 +480,9 @@ export class PushProcessor { return this.eventFulfillsRoomMemberCountCondition(cond, ev); case ConditionKind.SenderNotificationPermission: return this.eventFulfillsSenderNotifPermCondition(cond, ev); + case ConditionKind.RecipientPermission: + case ConditionKind.RecipientPermissionPrefix: + return this.eventFulfillsRecipientPermCondition(cond, ev); case ConditionKind.CallStarted: case ConditionKind.CallStartedPrefix: return this.eventFulfillsCallStartedCondition(cond, ev); @@ -510,6 +514,41 @@ export class PushProcessor { return room.currentState.mayTriggerNotifOfType(notifLevelKey, ev.getSender()!); } + /** + * MSCxxxx `recipient_permission` condition: matches if the user these push + * rules are being evaluated for (i.e. us) has a power level at least that + * required to perform the `m.room.power_levels` action named by `key` + * (e.g. "invite"), in the room the event is in. + */ + private eventFulfillsRecipientPermCondition(cond: IRecipientPermissionCondition, ev: MatrixEvent): boolean { + const actionKey = cond["key"]; + // Only the power-levels permission actions are valid keys. + const defaultLevels: Record = { invite: 0, kick: 50, ban: 50, redact: 50 }; + if (!actionKey || !(actionKey in defaultLevels)) { + return false; + } + + const room = this.client.getRoom(ev.getRoomId()); + const userId = this.client.getUserId(); + if (!room?.currentState || !userId) { + return false; + } + + // Note that this should not be the current state of the room but the state at + // the point the event is in the DAG. Unfortunately the js-sdk does not store + // this. + const plContent = room.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() ?? {}; + const requiredLevel = + typeof plContent[actionKey] === "number" ? plContent[actionKey] : defaultLevels[actionKey]; + const ourLevel = + typeof plContent.users?.[userId] === "number" + ? plContent.users[userId] + : typeof plContent.users_default === "number" + ? plContent.users_default + : 0; + return ourLevel >= requiredLevel; + } + private eventFulfillsRoomMemberCountCondition(cond: IRoomMemberCountCondition, ev: MatrixEvent): boolean { if (!cond.is) { return false; From cbb822f430b7632047c079225d0713ef393f2f1e Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 14 Jul 2026 13:24:18 +0100 Subject: [PATCH 2/2] Adopt MSC4506 identifiers for the knock push rule The knock push rules MSC has been published as MSC4506: rename the condition kind to org.matrix.msc4506.recipient_permission and the unstable rule id to .org.matrix.msc4506.rule.knock per its unstable prefix section. --- spec/unit/pushprocessor.spec.ts | 4 ++-- src/@types/PushRules.ts | 4 ++-- src/pushprocessor.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/unit/pushprocessor.spec.ts b/spec/unit/pushprocessor.spec.ts index b266f43bd4a..da23a0deadc 100644 --- a/spec/unit/pushprocessor.spec.ts +++ b/spec/unit/pushprocessor.spec.ts @@ -1050,13 +1050,13 @@ describe("getPushRuleGlobRegex", () => { }); }); -describe("recipient_permission condition (MSCxxxx knock push rule)", () => { +describe("recipient_permission condition (MSC4506 knock push rule)", () => { const roomId = "!knockroom:server"; const adminId = "@admin:server"; const knockerId = "@knocker:server"; const knockRule: IPushRule = { - rule_id: ".org.matrix.mscxxxx.rule.knock", + rule_id: ".org.matrix.msc4506.rule.knock", default: true, enabled: true, conditions: [ diff --git a/src/@types/PushRules.ts b/src/@types/PushRules.ts index 3348ee4ff38..134b4795b43 100644 --- a/src/@types/PushRules.ts +++ b/src/@types/PushRules.ts @@ -65,7 +65,7 @@ export enum ConditionKind { RoomMemberCount = "room_member_count", SenderNotificationPermission = "sender_notification_permission", RecipientPermission = "recipient_permission", - RecipientPermissionPrefix = "org.matrix.mscxxxx.recipient_permission", + RecipientPermissionPrefix = "org.matrix.msc4506.recipient_permission", CallStarted = "call_started", CallStartedPrefix = "org.matrix.msc3914.call_started", } @@ -153,7 +153,7 @@ export enum RuleId { EncryptedMessage = ".m.rule.encrypted", InviteToSelf = ".m.rule.invite_for_me", Knock = ".m.rule.knock", - KnockUnstable = ".org.matrix.mscxxxx.rule.knock", + KnockUnstable = ".org.matrix.msc4506.rule.knock", MemberEvent = ".m.rule.member_event", IncomingCall = ".m.rule.call", SuppressNotices = ".m.rule.suppress_notices", diff --git a/src/pushprocessor.ts b/src/pushprocessor.ts index 42ce670f88f..8d4400e5c7f 100644 --- a/src/pushprocessor.ts +++ b/src/pushprocessor.ts @@ -515,7 +515,7 @@ export class PushProcessor { } /** - * MSCxxxx `recipient_permission` condition: matches if the user these push + * MSC4506 `recipient_permission` condition: matches if the user these push * rules are being evaluated for (i.e. us) has a power level at least that * required to perform the `m.room.power_levels` action named by `key` * (e.g. "invite"), in the room the event is in.