matrix-nodejs-homeserver 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +75 -0
  2. package/README.md +457 -2
  3. package/dist/adapters/auth.d.ts +50 -0
  4. package/dist/adapters/auth.js +18 -0
  5. package/dist/adapters/cluster.d.ts +55 -0
  6. package/dist/adapters/cluster.js +249 -0
  7. package/dist/adapters/media.d.ts +50 -0
  8. package/dist/adapters/media.js +63 -0
  9. package/dist/adapters/s3.d.ts +33 -0
  10. package/dist/adapters/s3.js +95 -0
  11. package/dist/api/account.js +560 -0
  12. package/dist/api/admin.js +251 -0
  13. package/dist/api/appservice.js +33 -0
  14. package/dist/api/backup.js +269 -0
  15. package/dist/api/federation.js +792 -0
  16. package/dist/api/filters.js +87 -0
  17. package/dist/api/index.js +47 -0
  18. package/dist/api/legacy.js +170 -0
  19. package/dist/api/media.js +255 -0
  20. package/dist/api/misc.js +86 -0
  21. package/dist/api/preview.js +200 -0
  22. package/dist/api/profile.js +165 -0
  23. package/dist/api/push.js +187 -0
  24. package/dist/api/relationships.js +264 -0
  25. package/dist/api/rooms.js +1364 -0
  26. package/dist/api/rtc.d.ts +24 -0
  27. package/dist/api/rtc.js +52 -0
  28. package/dist/api/search.js +155 -0
  29. package/dist/api/slidingsync.js +464 -0
  30. package/dist/api/spaces.js +172 -0
  31. package/dist/api/sync.js +600 -0
  32. package/dist/api/threadsubs.js +129 -0
  33. package/dist/api/threepid.js +122 -0
  34. package/dist/api/uia.js +126 -0
  35. package/dist/api/user.js +286 -0
  36. package/dist/core/admin.d.ts +85 -0
  37. package/dist/core/admin.js +307 -0
  38. package/dist/core/adminroom.d.ts +49 -0
  39. package/dist/core/adminroom.js +482 -0
  40. package/dist/core/appservices.d.ts +73 -0
  41. package/dist/core/appservices.js +368 -0
  42. package/dist/core/audit.d.ts +51 -0
  43. package/dist/core/audit.js +86 -0
  44. package/dist/core/auth.d.ts +48 -0
  45. package/dist/core/auth.js +237 -0
  46. package/dist/core/authrules.d.ts +4 -0
  47. package/dist/core/authrules.js +347 -0
  48. package/dist/core/cluster.d.ts +66 -0
  49. package/dist/core/cluster.js +191 -0
  50. package/dist/core/crypto.d.ts +10 -0
  51. package/dist/core/crypto.js +100 -0
  52. package/dist/core/delayed.d.ts +39 -0
  53. package/dist/core/delayed.js +159 -0
  54. package/dist/core/errors.d.ts +9 -0
  55. package/dist/core/errors.js +45 -0
  56. package/dist/core/events.d.ts +32 -0
  57. package/dist/core/events.js +185 -0
  58. package/dist/core/glob.js +66 -0
  59. package/dist/core/identity.d.ts +24 -0
  60. package/dist/core/identity.js +96 -0
  61. package/dist/core/ids.js +36 -0
  62. package/dist/core/invitefilter.js +35 -0
  63. package/dist/core/json.js +16 -0
  64. package/dist/core/keys.d.ts +36 -0
  65. package/dist/core/keys.js +449 -0
  66. package/dist/core/notifier.d.ts +17 -0
  67. package/dist/core/notifier.js +54 -0
  68. package/dist/core/oauth.d.ts +38 -0
  69. package/dist/core/oauth.js +108 -0
  70. package/dist/core/partialstate.d.ts +32 -0
  71. package/dist/core/partialstate.js +197 -0
  72. package/dist/core/phone.js +268 -0
  73. package/dist/core/policy.d.ts +29 -0
  74. package/dist/core/policy.js +96 -0
  75. package/dist/core/profile.js +39 -0
  76. package/dist/core/push.d.ts +47 -0
  77. package/dist/core/push.js +306 -0
  78. package/dist/core/pushrules.d.ts +1 -0
  79. package/dist/core/pushrules.js +228 -0
  80. package/dist/core/ratelimit.d.ts +23 -0
  81. package/dist/core/ratelimit.js +77 -0
  82. package/dist/core/rooms.d.ts +215 -0
  83. package/dist/core/rooms.js +1729 -0
  84. package/dist/core/settings.d.ts +11 -0
  85. package/dist/core/settings.js +29 -0
  86. package/dist/core/sso.d.ts +46 -0
  87. package/dist/core/sso.js +165 -0
  88. package/dist/core/stateres.d.ts +4 -0
  89. package/dist/core/stateres.js +208 -0
  90. package/dist/core/threepid.d.ts +80 -0
  91. package/dist/core/threepid.js +275 -0
  92. package/dist/core/typing.d.ts +16 -0
  93. package/dist/core/typing.js +60 -0
  94. package/dist/federation/client.d.ts +51 -0
  95. package/dist/federation/client.js +278 -0
  96. package/dist/federation/index.d.ts +153 -0
  97. package/dist/federation/index.js +1058 -0
  98. package/dist/federation/keyring.d.ts +26 -0
  99. package/dist/federation/keyring.js +122 -0
  100. package/dist/federation/sender.d.ts +26 -0
  101. package/dist/federation/sender.js +164 -0
  102. package/dist/homeserver.d.ts +223 -0
  103. package/dist/homeserver.js +301 -0
  104. package/dist/http/adapters.d.ts +40 -0
  105. package/dist/http/adapters.js +130 -0
  106. package/dist/http/handler.d.ts +8 -0
  107. package/dist/http/handler.js +202 -0
  108. package/dist/http/input.js +24 -0
  109. package/dist/http/router.d.ts +60 -0
  110. package/dist/http/router.js +76 -0
  111. package/dist/index.d.ts +25 -0
  112. package/dist/index.js +14 -0
  113. package/dist/storage/memory.d.ts +5 -0
  114. package/dist/storage/memory.js +122 -0
  115. package/dist/storage/schema.d.ts +4 -0
  116. package/dist/storage/schema.js +664 -0
  117. package/dist/storage/sql.d.ts +72 -0
  118. package/dist/storage/sql.js +274 -0
  119. package/dist/storage/types.d.ts +55 -0
  120. package/package.json +67 -4
@@ -0,0 +1,306 @@
1
+ import { isObject } from "./json.js";
2
+ import { splitStateKey, toClientEvent } from "./events.js";
3
+ import { getState, powerLevels, userLevel } from "./authrules.js";
4
+ import { globMatch, globSearchWords } from "./glob.js";
5
+ import { KINDS, loadRules } from "./pushrules.js";
6
+ import { isSubscribed } from "../api/threadsubs.js";
7
+ //#region src/core/push.ts
8
+ /** Look up a dotted path; `\.` escapes a literal dot in a key (e.g. `content.m\.relates_to`). */
9
+ function valueAtPath(obj, path) {
10
+ const parts = path.split(/(?<!\\)\./).map((p) => p.replace(/\\\./g, ".").replace(/\\\\/g, "\\"));
11
+ let cur = obj;
12
+ for (const p of parts) {
13
+ if (!isObject(cur) || !(p in cur)) return void 0;
14
+ cur = cur[p];
15
+ }
16
+ return cur;
17
+ }
18
+ function conditionMatches(cond, ctx) {
19
+ switch (cond.kind) {
20
+ case "event_match": {
21
+ if (typeof cond.key !== "string" || typeof cond.pattern !== "string") return false;
22
+ const value = valueAtPath(ctx.event, cond.key);
23
+ if (typeof value !== "string") return false;
24
+ return cond.key === "content.body" ? globSearchWords(cond.pattern, value) : globMatch(cond.pattern, value, { caseInsensitive: true });
25
+ }
26
+ case "event_property_is": return typeof cond.key === "string" && valueAtPath(ctx.event, cond.key) === cond.value;
27
+ case "event_property_contains": {
28
+ const value = typeof cond.key === "string" ? valueAtPath(ctx.event, cond.key) : void 0;
29
+ return Array.isArray(value) && value.includes(cond.value);
30
+ }
31
+ case "contains_display_name": {
32
+ const body = ctx.event.content?.body;
33
+ if (!ctx.displayName || typeof body !== "string") return false;
34
+ const escaped = ctx.displayName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35
+ return new RegExp(`(^|\\W)${escaped}(\\W|$)`, "i").test(body);
36
+ }
37
+ case "room_member_count": {
38
+ const m = /^(==|<=|>=|<|>)?(\d+)$/.exec(String(cond.is ?? ""));
39
+ if (!m) return false;
40
+ const n = Number(m[2]);
41
+ switch (m[1] ?? "==") {
42
+ case "==": return ctx.memberCount === n;
43
+ case "<": return ctx.memberCount < n;
44
+ case ">": return ctx.memberCount > n;
45
+ case "<=": return ctx.memberCount <= n;
46
+ case ">=": return ctx.memberCount >= n;
47
+ }
48
+ return false;
49
+ }
50
+ case "io.element.msc4306.thread_subscription": return ctx.threadSubscribed !== void 0 && ctx.threadSubscribed !== null && ctx.threadSubscribed === cond.subscribed;
51
+ case "sender_notification_permission": return typeof cond.key === "string" && ctx.senderLevel >= (ctx.notificationLevels[cond.key] ?? 50);
52
+ default: return false;
53
+ }
54
+ }
55
+ function ruleMatches(kind, rule, ctx) {
56
+ if (!rule.enabled) return false;
57
+ switch (kind) {
58
+ case "override":
59
+ case "postcontent":
60
+ case "underride": return (rule.conditions ?? []).every((c) => conditionMatches(c, ctx));
61
+ case "content": return typeof rule.pattern === "string" && conditionMatches({
62
+ kind: "event_match",
63
+ key: "content.body",
64
+ pattern: rule.pattern
65
+ }, ctx);
66
+ case "room": return ctx.event.room_id === rule.rule_id;
67
+ case "sender": return ctx.event.sender === rule.rule_id;
68
+ }
69
+ return false;
70
+ }
71
+ /** Evaluate a user's push rules against an event; returns the actions of the first matching rule. */
72
+ function evaluateRules(rules, ctx) {
73
+ for (const kind of KINDS) for (const rule of rules[kind] ?? []) if (ruleMatches(kind, rule, ctx)) return rule.actions ?? [];
74
+ return null;
75
+ }
76
+ function tweaksOf(actions) {
77
+ const tweaks = {};
78
+ for (const a of actions) if (isObject(a) && typeof a.set_tweak === "string") tweaks[a.set_tweak] = "value" in a ? a.value : true;
79
+ return tweaks;
80
+ }
81
+ /** Thread an event belongs to for notification purposes ("main" for the main timeline). */
82
+ function threadIdOf(event) {
83
+ const rel = event.content?.["m.relates_to"];
84
+ return isObject(rel) && rel.rel_type === "m.thread" && typeof rel.event_id === "string" ? rel.event_id : "main";
85
+ }
86
+ /**
87
+ * Push rule evaluation and push gateway delivery. Every new event is evaluated for
88
+ * every local member; matches are stored for unread counts and `/notifications`.
89
+ */
90
+ var PushService = class {
91
+ hs;
92
+ constructor(hs) {
93
+ this.hs = hs;
94
+ }
95
+ async onEvent(ev, state, version) {
96
+ const event = ev.pdu;
97
+ const members = [];
98
+ for (const [key, member] of state) {
99
+ const [type, userId] = splitStateKey(key);
100
+ if (type !== "m.room.member") continue;
101
+ const m = member.content?.membership;
102
+ if (m === "join" || m === "invite" && event.type === "m.room.member" && event.state_key === userId) members.push(userId);
103
+ }
104
+ const joined = members.filter((u) => getState(state, "m.room.member", u)?.content?.membership === "join");
105
+ const pl = powerLevels(state, version);
106
+ const senderLevel = userLevel(pl, event.sender);
107
+ const thread = threadIdOf(event);
108
+ for (const userId of members) {
109
+ if (userId === event.sender || !this.hs.isLocalUser(userId)) continue;
110
+ const actions = evaluateRules(await loadRules(this.hs, userId), {
111
+ event,
112
+ userId,
113
+ displayName: getState(state, "m.room.member", userId)?.content?.displayname ?? null,
114
+ memberCount: joined.length,
115
+ senderLevel: Number.isFinite(senderLevel) ? senderLevel : Number.MAX_SAFE_INTEGER,
116
+ notificationLevels: pl.notifications,
117
+ threadSubscribed: thread === "main" ? void 0 : await isSubscribed(this.hs, userId, event.room_id, thread)
118
+ });
119
+ if (!actions || !actions.includes("notify")) continue;
120
+ const tweaks = tweaksOf(actions);
121
+ await this.hs.db.upsert("notifications", {
122
+ user_id: userId,
123
+ room_id: event.room_id,
124
+ event_id: event.event_id,
125
+ stream: ev.stream,
126
+ highlight: tweaks.highlight === true,
127
+ thread_id: threadIdOf(event),
128
+ actions,
129
+ ts: Date.now()
130
+ });
131
+ this.deliver(userId, ev, state, tweaks).catch((err) => this.hs.logger.warn("Push delivery failed", err));
132
+ }
133
+ }
134
+ /** Unread notification counts since the user's read receipts. */
135
+ /** Stream positions up to which the user has read the room (unthreaded) and each thread. */
136
+ async readPositions(userId, roomId) {
137
+ const receipts = await this.hs.db.findMany("receipts", { where: [{
138
+ field: "room_id",
139
+ value: roomId
140
+ }, {
141
+ field: "user_id",
142
+ value: userId
143
+ }] });
144
+ let unthreaded = 0;
145
+ const threadRead = /* @__PURE__ */ new Map();
146
+ for (const r of receipts) {
147
+ if (r.receipt_type !== "m.read" && r.receipt_type !== "m.read.private") continue;
148
+ const target = await this.hs.rooms.getEvent(r.event_id);
149
+ if (!target) continue;
150
+ if (r.thread_id === "") unthreaded = Math.max(unthreaded, target.stream);
151
+ else threadRead.set(r.thread_id, Math.max(threadRead.get(r.thread_id) ?? 0, target.stream));
152
+ }
153
+ return {
154
+ unthreaded,
155
+ threads: threadRead
156
+ };
157
+ }
158
+ isRead(row, read) {
159
+ return row.stream <= read.unthreaded || row.stream <= (read.threads.get(row.thread_id) ?? 0);
160
+ }
161
+ async unreadCounts(userId, roomId) {
162
+ const read = await this.readPositions(userId, roomId);
163
+ const unthreaded = read.unthreaded;
164
+ const rows = await this.hs.db.findMany("notifications", { where: [
165
+ {
166
+ field: "user_id",
167
+ value: userId
168
+ },
169
+ {
170
+ field: "room_id",
171
+ value: roomId
172
+ },
173
+ {
174
+ field: "stream",
175
+ operator: "gt",
176
+ value: unthreaded
177
+ }
178
+ ] });
179
+ const room = {
180
+ notification_count: 0,
181
+ highlight_count: 0
182
+ };
183
+ const threads = {};
184
+ for (const n of rows) {
185
+ if (this.isRead(n, read)) continue;
186
+ const bucket = n.thread_id === "main" ? room : threads[n.thread_id] ??= {
187
+ notification_count: 0,
188
+ highlight_count: 0
189
+ };
190
+ bucket.notification_count++;
191
+ if (n.highlight) bucket.highlight_count++;
192
+ }
193
+ return {
194
+ room,
195
+ threads
196
+ };
197
+ }
198
+ async deliver(userId, ev, state, tweaks) {
199
+ const pushers = await this.hs.db.findMany("pushers", { where: [{
200
+ field: "user_id",
201
+ value: userId
202
+ }] });
203
+ if (pushers.length === 0) return;
204
+ const event = ev.pdu;
205
+ const unread = (await this.unreadCounts(userId, event.room_id)).room.notification_count;
206
+ for (const row of pushers) {
207
+ const pusher = row.pusher;
208
+ if (pusher.kind !== "http" || typeof pusher.data?.url !== "string") continue;
209
+ const { url, format, ...data } = pusher.data;
210
+ const notification = {
211
+ event_id: event.event_id,
212
+ room_id: event.room_id,
213
+ counts: { unread },
214
+ devices: [{
215
+ app_id: pusher.app_id,
216
+ pushkey: pusher.pushkey,
217
+ pushkey_ts: Math.floor(Date.now() / 1e3),
218
+ data,
219
+ tweaks
220
+ }],
221
+ prio: tweaks.highlight || tweaks.sound ? "high" : "low"
222
+ };
223
+ if (format !== "event_id_only") {
224
+ notification.type = event.type;
225
+ notification.sender = event.sender;
226
+ notification.content = event.content;
227
+ const name = getState(state, "m.room.name")?.content?.name;
228
+ if (typeof name === "string") notification.room_name = name;
229
+ const senderName = getState(state, "m.room.member", event.sender)?.content?.displayname;
230
+ if (typeof senderName === "string") notification.sender_display_name = senderName;
231
+ if (event.type === "m.room.member") {
232
+ notification.membership = event.content?.membership;
233
+ notification.user_is_target = event.state_key === userId;
234
+ }
235
+ }
236
+ const res = await this.hs.fetch(url, {
237
+ method: "POST",
238
+ headers: { "content-type": "application/json" },
239
+ body: JSON.stringify({ notification }),
240
+ signal: AbortSignal.timeout(1e4)
241
+ });
242
+ if (!res.ok) {
243
+ this.hs.logger.warn("Push gateway returned", res.status, "for", url);
244
+ continue;
245
+ }
246
+ const body = await res.json().catch(() => ({}));
247
+ if (Array.isArray(body.rejected) && body.rejected.includes(pusher.pushkey)) await this.hs.db.delete("pushers", [
248
+ {
249
+ field: "user_id",
250
+ value: userId
251
+ },
252
+ {
253
+ field: "app_id",
254
+ value: pusher.app_id
255
+ },
256
+ {
257
+ field: "pushkey",
258
+ value: pusher.pushkey
259
+ }
260
+ ]);
261
+ }
262
+ }
263
+ /** Rows for `GET /notifications`. */
264
+ async list(userId, opts) {
265
+ const where = [{
266
+ field: "user_id",
267
+ value: userId
268
+ }];
269
+ if (opts.from !== null) where.push({
270
+ field: "stream",
271
+ operator: "lt",
272
+ value: opts.from
273
+ });
274
+ if (opts.onlyHighlight) where.push({
275
+ field: "highlight",
276
+ value: true
277
+ });
278
+ const rows = await this.hs.db.findMany("notifications", {
279
+ where,
280
+ sortBy: {
281
+ field: "stream",
282
+ direction: "desc"
283
+ },
284
+ limit: opts.limit
285
+ });
286
+ const out = [];
287
+ const readByRoom = /* @__PURE__ */ new Map();
288
+ for (const row of rows) {
289
+ const ev = await this.hs.rooms.getEvent(row.event_id);
290
+ if (!ev) continue;
291
+ let read = readByRoom.get(row.room_id);
292
+ if (!read) readByRoom.set(row.room_id, read = await this.readPositions(userId, row.room_id));
293
+ out.push({
294
+ actions: row.actions,
295
+ event: toClientEvent(ev.pdu),
296
+ room_id: row.room_id,
297
+ ts: row.ts,
298
+ read: this.isRead(row, read),
299
+ stream: row.stream
300
+ });
301
+ }
302
+ return out;
303
+ }
304
+ };
305
+ //#endregion
306
+ export { PushService, evaluateRules, threadIdOf, tweaksOf, valueAtPath };
@@ -0,0 +1 @@
1
+ import "../homeserver.js";
@@ -0,0 +1,228 @@
1
+ import { isObject } from "./json.js";
2
+ //#region src/core/pushrules.ts
3
+ const KINDS = [
4
+ "override",
5
+ "content",
6
+ "postcontent",
7
+ "room",
8
+ "sender",
9
+ "underride"
10
+ ];
11
+ function defaultRules(userId) {
12
+ const localpart = userId.slice(1).split(":")[0];
13
+ const notify = ["notify"];
14
+ const sound = (s) => ({
15
+ set_tweak: "sound",
16
+ value: s
17
+ });
18
+ const highlight = { set_tweak: "highlight" };
19
+ const r = (rule_id, conditions, actions, extra = {}) => ({
20
+ rule_id,
21
+ default: true,
22
+ enabled: true,
23
+ actions,
24
+ ...conditions ? { conditions } : {},
25
+ ...extra
26
+ });
27
+ const em = (key, pattern) => ({
28
+ kind: "event_match",
29
+ key,
30
+ pattern
31
+ });
32
+ const ep = (key, value) => ({
33
+ kind: "event_property_is",
34
+ key,
35
+ value
36
+ });
37
+ return {
38
+ override: [
39
+ r(".m.rule.master", [], [], { enabled: false }),
40
+ r(".m.rule.suppress_notices", [em("content.msgtype", "m.notice")], []),
41
+ r(".m.rule.invite_for_me", [
42
+ em("type", "m.room.member"),
43
+ em("content.membership", "invite"),
44
+ em("state_key", userId)
45
+ ], [...notify, sound("default")]),
46
+ r(".m.rule.member_event", [em("type", "m.room.member")], []),
47
+ r(".m.rule.is_user_mention", [{
48
+ kind: "event_property_contains",
49
+ key: "content.m\\.mentions.user_ids",
50
+ value: userId
51
+ }], [
52
+ ...notify,
53
+ sound("default"),
54
+ highlight
55
+ ]),
56
+ r(".m.rule.contains_display_name", [{ kind: "contains_display_name" }], [
57
+ ...notify,
58
+ sound("default"),
59
+ highlight
60
+ ]),
61
+ r(".m.rule.is_room_mention", [ep("content.m\\.mentions.room", true), {
62
+ kind: "sender_notification_permission",
63
+ key: "room"
64
+ }], [...notify, highlight]),
65
+ r(".m.rule.roomnotif", [em("content.body", "@room"), {
66
+ kind: "sender_notification_permission",
67
+ key: "room"
68
+ }], [...notify, highlight]),
69
+ r(".m.rule.tombstone", [em("type", "m.room.tombstone"), em("state_key", "")], [...notify, highlight]),
70
+ r(".m.rule.reaction", [em("type", "m.reaction")], []),
71
+ r(".m.rule.room.server_acl", [em("type", "m.room.server_acl"), em("state_key", "")], []),
72
+ r(".m.rule.suppress_edits", [ep("content.m\\.relates_to.rel_type", "m.replace")], []),
73
+ r(".org.matrix.msc3930.rule.poll_response", [em("type", "org.matrix.msc3381.poll.response")], [])
74
+ ],
75
+ content: [r(".m.rule.contains_user_name", void 0, [
76
+ ...notify,
77
+ sound("default"),
78
+ highlight
79
+ ], { pattern: localpart })],
80
+ postcontent: [r(".io.element.msc4306.rule.unsubscribed_thread", [{
81
+ kind: "io.element.msc4306.thread_subscription",
82
+ subscribed: false
83
+ }], []), r(".io.element.msc4306.rule.subscribed_thread", [{
84
+ kind: "io.element.msc4306.thread_subscription",
85
+ subscribed: true
86
+ }], [...notify, sound("default")])],
87
+ room: [],
88
+ sender: [],
89
+ underride: [
90
+ r(".m.rule.call", [em("type", "m.call.invite")], [...notify, sound("ring")]),
91
+ r(".m.rule.encrypted_room_one_to_one", [{
92
+ kind: "room_member_count",
93
+ is: "2"
94
+ }, em("type", "m.room.encrypted")], [...notify, sound("default")]),
95
+ r(".m.rule.room_one_to_one", [{
96
+ kind: "room_member_count",
97
+ is: "2"
98
+ }, em("type", "m.room.message")], [...notify, sound("default")]),
99
+ r(".m.rule.message", [em("type", "m.room.message")], notify),
100
+ r(".org.matrix.msc3930.rule.poll_start_one_to_one", [{
101
+ kind: "room_member_count",
102
+ is: "2"
103
+ }, em("type", "org.matrix.msc3381.poll.start")], [...notify, sound("default")]),
104
+ r(".org.matrix.msc3930.rule.poll_end_one_to_one", [{
105
+ kind: "room_member_count",
106
+ is: "2"
107
+ }, em("type", "org.matrix.msc3381.poll.end")], [...notify, sound("default")]),
108
+ r(".org.matrix.msc3930.rule.poll_start", [em("type", "org.matrix.msc3381.poll.start")], notify),
109
+ r(".org.matrix.msc3930.rule.poll_end", [em("type", "org.matrix.msc3381.poll.end")], notify),
110
+ r(".m.rule.encrypted", [em("type", "m.room.encrypted")], notify)
111
+ ]
112
+ };
113
+ }
114
+ /** Serialise read-modify-write cycles on a user's push rules (cluster-wide). */
115
+ function withRulesLock(hs, userId, fn) {
116
+ return hs.cluster.lock(`pushrules:${userId}`, () => {
117
+ cacheFor(hs).delete(userId);
118
+ return fn();
119
+ });
120
+ }
121
+ const rulesCache = /* @__PURE__ */ new WeakMap();
122
+ function cacheFor(hs) {
123
+ let c = rulesCache.get(hs);
124
+ if (!c) {
125
+ const cache = /* @__PURE__ */ new Map();
126
+ rulesCache.set(hs, c = cache);
127
+ hs.cluster.subscribe("pushrules", (msg) => {
128
+ if (msg?.from !== hs.instanceId) cache.delete(msg.userId);
129
+ });
130
+ }
131
+ return c;
132
+ }
133
+ async function loadRules(hs, userId) {
134
+ const cached = cacheFor(hs).get(userId);
135
+ if (cached) return structuredClone(cached);
136
+ const rules = await readRules(hs, userId);
137
+ const cache = cacheFor(hs);
138
+ if (cache.size > 5e3) cache.clear();
139
+ cache.set(userId, rules);
140
+ return structuredClone(rules);
141
+ }
142
+ async function readRules(hs, userId) {
143
+ const row = await hs.db.findOne("account_data", [
144
+ {
145
+ field: "user_id",
146
+ value: userId
147
+ },
148
+ {
149
+ field: "room_id",
150
+ value: ""
151
+ },
152
+ {
153
+ field: "type",
154
+ value: "m.push_rules"
155
+ }
156
+ ]);
157
+ if (!isObject(row?.content?.global)) return defaultRules(userId);
158
+ const rules = row.content.global;
159
+ const defaults = defaultRules(userId);
160
+ for (const kind of KINDS) if (!Array.isArray(rules[kind])) rules[kind] = defaults[kind];
161
+ return rules;
162
+ }
163
+ async function saveRules(hs, userId, rules) {
164
+ cacheFor(hs).set(userId, structuredClone(rules));
165
+ hs.cluster.publish("pushrules", {
166
+ from: hs.instanceId,
167
+ userId
168
+ }).catch(() => {});
169
+ await hs.streams.write((stream) => hs.db.upsert("account_data", {
170
+ user_id: userId,
171
+ room_id: "",
172
+ type: "m.push_rules",
173
+ content: { global: rules },
174
+ stream
175
+ }));
176
+ hs.notifier.notify([userId]);
177
+ }
178
+ /** Copy room-specific push rules from an upgraded room's predecessor to the new room. */
179
+ function copyRoomPushRules(hs, userId, oldRoomId, newRoomId) {
180
+ return withRulesLock(hs, userId, () => copyRules(hs, userId, oldRoomId, newRoomId));
181
+ }
182
+ async function copyRules(hs, userId, oldRoomId, newRoomId) {
183
+ const row = await hs.db.findOne("account_data", [
184
+ {
185
+ field: "user_id",
186
+ value: userId
187
+ },
188
+ {
189
+ field: "room_id",
190
+ value: ""
191
+ },
192
+ {
193
+ field: "type",
194
+ value: "m.push_rules"
195
+ }
196
+ ]);
197
+ if (!isObject(row?.content?.global)) return;
198
+ const rules = row.content.global;
199
+ let changed = false;
200
+ for (const kind of KINDS) {
201
+ const list = rules[kind] ?? [];
202
+ const additions = [];
203
+ for (const rule of list) {
204
+ if (rule.default) continue;
205
+ if (kind === "room" && rule.rule_id === oldRoomId) {
206
+ if (!list.some((r) => r.rule_id === newRoomId)) additions.push({
207
+ ...structuredClone(rule),
208
+ rule_id: newRoomId
209
+ });
210
+ } else if (Array.isArray(rule.conditions) && rule.conditions.some((c) => c?.key === "room_id" && c?.pattern === oldRoomId)) {
211
+ const copy = structuredClone(rule);
212
+ copy.rule_id = `${rule.rule_id}-${newRoomId}`;
213
+ copy.conditions = copy.conditions.map((c) => c?.key === "room_id" && c?.pattern === oldRoomId ? {
214
+ ...c,
215
+ pattern: newRoomId
216
+ } : c);
217
+ if (!list.some((r) => r.rule_id === copy.rule_id)) additions.push(copy);
218
+ }
219
+ }
220
+ if (additions.length) {
221
+ rules[kind] = [...additions, ...list];
222
+ changed = true;
223
+ }
224
+ }
225
+ if (changed) await saveRules(hs, userId, rules);
226
+ }
227
+ //#endregion
228
+ export { KINDS, copyRoomPushRules, defaultRules, loadRules, saveRules, withRulesLock };
@@ -0,0 +1,23 @@
1
+ //#region src/core/ratelimit.d.ts
2
+ /** A rate limiter keyed by an arbitrary string. Implement this to share limits across instances (e.g. Redis). */
3
+ export interface RateLimiter {
4
+ /** Consume one unit for `key` under `rule`; resolve with the delay to wait (0 when allowed). */
5
+ consume(key: string, rule: RateLimitRule): Promise<number>;
6
+ }
7
+ export interface RateLimitRule {
8
+ /** Sustained requests per second. */
9
+ perSecond: number;
10
+ /** Burst size. */
11
+ burst: number;
12
+ }
13
+ export type RateLimitAction = "login" | "register" | "message" | "join" | "invite" | "upload" | "createRoom" | "keyQuery";
14
+ /** In-process token bucket limiter. */
15
+ export declare function memoryRateLimiter(): RateLimiter;
16
+ export declare class RateLimits {
17
+ private limiter;
18
+ private rules;
19
+ constructor(limiter: RateLimiter | null, overrides?: Partial<Record<RateLimitAction, RateLimitRule>>);
20
+ /** Throw M_LIMIT_EXCEEDED when `key` exceeded the limit for `action`. Exempt keys are never limited. */
21
+ check(action: RateLimitAction, key: string): Promise<void>;
22
+ }
23
+ //#endregion
@@ -0,0 +1,77 @@
1
+ import { MatrixError } from "./errors.js";
2
+ //#region src/core/ratelimit.ts
3
+ const DEFAULT_RATE_LIMITS = {
4
+ login: {
5
+ perSecond: .17,
6
+ burst: 5
7
+ },
8
+ register: {
9
+ perSecond: .17,
10
+ burst: 3
11
+ },
12
+ message: {
13
+ perSecond: 1,
14
+ burst: 20
15
+ },
16
+ join: {
17
+ perSecond: .5,
18
+ burst: 10
19
+ },
20
+ invite: {
21
+ perSecond: .3,
22
+ burst: 10
23
+ },
24
+ upload: {
25
+ perSecond: 1,
26
+ burst: 10
27
+ },
28
+ createRoom: {
29
+ perSecond: .1,
30
+ burst: 5
31
+ },
32
+ keyQuery: {
33
+ perSecond: 5,
34
+ burst: 50
35
+ }
36
+ };
37
+ /** In-process token bucket limiter. */
38
+ function memoryRateLimiter() {
39
+ const buckets = /* @__PURE__ */ new Map();
40
+ return { async consume(key, rule) {
41
+ const now = Date.now();
42
+ let b = buckets.get(key);
43
+ if (!b) {
44
+ if (buckets.size > 1e5) buckets.clear();
45
+ buckets.set(key, b = {
46
+ tokens: rule.burst,
47
+ updated: now
48
+ });
49
+ }
50
+ b.tokens = Math.min(rule.burst, b.tokens + (now - b.updated) / 1e3 * rule.perSecond);
51
+ b.updated = now;
52
+ if (b.tokens >= 1) {
53
+ b.tokens -= 1;
54
+ return 0;
55
+ }
56
+ return Math.ceil((1 - b.tokens) / rule.perSecond * 1e3);
57
+ } };
58
+ }
59
+ var RateLimits = class {
60
+ limiter;
61
+ rules;
62
+ constructor(limiter, overrides = {}) {
63
+ this.limiter = limiter;
64
+ this.rules = {
65
+ ...DEFAULT_RATE_LIMITS,
66
+ ...overrides
67
+ };
68
+ }
69
+ /** Throw M_LIMIT_EXCEEDED when `key` exceeded the limit for `action`. Exempt keys are never limited. */
70
+ async check(action, key) {
71
+ if (!this.limiter) return;
72
+ const wait = await this.limiter.consume(`${action}:${key}`, this.rules[action]);
73
+ if (wait > 0) throw new MatrixError(429, "M_LIMIT_EXCEEDED", "Too many requests", { retry_after_ms: wait });
74
+ }
75
+ };
76
+ //#endregion
77
+ export { DEFAULT_RATE_LIMITS, RateLimits, memoryRateLimiter };