matrix-nodejs-homeserver 0.0.0-stage → 0.3.1

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 (141) hide show
  1. package/LICENSE +75 -0
  2. package/README.md +479 -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 +594 -0
  12. package/dist/api/admin.js +267 -0
  13. package/dist/api/appservice.js +45 -0
  14. package/dist/api/backup.js +280 -0
  15. package/dist/api/federation.js +826 -0
  16. package/dist/api/filters.js +88 -0
  17. package/dist/api/index.js +47 -0
  18. package/dist/api/legacy.js +183 -0
  19. package/dist/api/media.js +271 -0
  20. package/dist/api/misc.js +95 -0
  21. package/dist/api/preview.js +201 -0
  22. package/dist/api/profile.js +176 -0
  23. package/dist/api/push.js +202 -0
  24. package/dist/api/relationships.js +270 -0
  25. package/dist/api/rooms.js +1487 -0
  26. package/dist/api/rtc.d.ts +23 -0
  27. package/dist/api/rtc.js +66 -0
  28. package/dist/api/search.js +161 -0
  29. package/dist/api/slidingsync.js +474 -0
  30. package/dist/api/spaces.js +177 -0
  31. package/dist/api/sync.js +609 -0
  32. package/dist/api/threadsubs.js +140 -0
  33. package/dist/api/threepid.js +155 -0
  34. package/dist/api/uia.js +127 -0
  35. package/dist/api/user.js +329 -0
  36. package/dist/core/admin.d.ts +84 -0
  37. package/dist/core/admin.js +308 -0
  38. package/dist/core/adminroom.d.ts +49 -0
  39. package/dist/core/adminroom.js +483 -0
  40. package/dist/core/appservices.d.ts +74 -0
  41. package/dist/core/appservices.js +369 -0
  42. package/dist/core/audit.d.ts +50 -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 +348 -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 +160 -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 +186 -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 +36 -0
  63. package/dist/core/json.js +16 -0
  64. package/dist/core/keys.d.ts +36 -0
  65. package/dist/core/keys.js +450 -0
  66. package/dist/core/m.d.ts +209 -0
  67. package/dist/core/m.js +182 -0
  68. package/dist/core/notifier.d.ts +17 -0
  69. package/dist/core/notifier.js +54 -0
  70. package/dist/core/oauth.d.ts +38 -0
  71. package/dist/core/oauth.js +108 -0
  72. package/dist/core/partialstate.d.ts +32 -0
  73. package/dist/core/partialstate.js +198 -0
  74. package/dist/core/phone.js +268 -0
  75. package/dist/core/policy.d.ts +29 -0
  76. package/dist/core/policy.js +97 -0
  77. package/dist/core/profile.js +39 -0
  78. package/dist/core/push.d.ts +46 -0
  79. package/dist/core/push.js +307 -0
  80. package/dist/core/pushrules.js +229 -0
  81. package/dist/core/ratelimit.d.ts +23 -0
  82. package/dist/core/ratelimit.js +77 -0
  83. package/dist/core/rooms.d.ts +215 -0
  84. package/dist/core/rooms.js +1731 -0
  85. package/dist/core/settings.d.ts +11 -0
  86. package/dist/core/settings.js +29 -0
  87. package/dist/core/sso.d.ts +46 -0
  88. package/dist/core/sso.js +165 -0
  89. package/dist/core/stateres.d.ts +4 -0
  90. package/dist/core/stateres.js +209 -0
  91. package/dist/core/threepid.d.ts +80 -0
  92. package/dist/core/threepid.js +275 -0
  93. package/dist/core/typing.d.ts +16 -0
  94. package/dist/core/typing.js +60 -0
  95. package/dist/federation/client.d.ts +51 -0
  96. package/dist/federation/client.js +279 -0
  97. package/dist/federation/index.d.ts +153 -0
  98. package/dist/federation/index.js +1059 -0
  99. package/dist/federation/keyring.d.ts +26 -0
  100. package/dist/federation/keyring.js +122 -0
  101. package/dist/federation/sender.d.ts +26 -0
  102. package/dist/federation/sender.js +164 -0
  103. package/dist/homeserver.d.ts +224 -0
  104. package/dist/homeserver.js +302 -0
  105. package/dist/http/adapters.d.ts +40 -0
  106. package/dist/http/adapters.js +130 -0
  107. package/dist/http/handler.d.ts +8 -0
  108. package/dist/http/handler.js +181 -0
  109. package/dist/http/input.js +24 -0
  110. package/dist/http/procedure.js +25 -0
  111. package/dist/http/router.d.ts +43 -0
  112. package/dist/http/router.js +56 -0
  113. package/dist/index.d.ts +26 -0
  114. package/dist/index.js +15 -0
  115. package/dist/storage/memory.d.ts +5 -0
  116. package/dist/storage/memory.js +122 -0
  117. package/dist/storage/schema.d.ts +4 -0
  118. package/dist/storage/schema.js +664 -0
  119. package/dist/storage/sql.d.ts +72 -0
  120. package/dist/storage/sql.js +274 -0
  121. package/dist/storage/types.d.ts +55 -0
  122. package/dist/vendor/zod/LICENSE +21 -0
  123. package/dist/vendor/zod/v4/classic/errors.js +54 -0
  124. package/dist/vendor/zod/v4/classic/parse.js +17 -0
  125. package/dist/vendor/zod/v4/classic/schemas.js +865 -0
  126. package/dist/vendor/zod/v4/core/api.js +512 -0
  127. package/dist/vendor/zod/v4/core/checks.js +300 -0
  128. package/dist/vendor/zod/v4/core/core.js +109 -0
  129. package/dist/vendor/zod/v4/core/doc.js +35 -0
  130. package/dist/vendor/zod/v4/core/errors.js +118 -0
  131. package/dist/vendor/zod/v4/core/json-schema-processors.js +428 -0
  132. package/dist/vendor/zod/v4/core/memoizer.js +255 -0
  133. package/dist/vendor/zod/v4/core/parse.js +206 -0
  134. package/dist/vendor/zod/v4/core/regexes.js +73 -0
  135. package/dist/vendor/zod/v4/core/registries.js +48 -0
  136. package/dist/vendor/zod/v4/core/schemas.js +1378 -0
  137. package/dist/vendor/zod/v4/core/to-json-schema.js +448 -0
  138. package/dist/vendor/zod/v4/core/util.js +537 -0
  139. package/dist/vendor/zod/v4/core/versions.js +8 -0
  140. package/dist/vendor/zod/v4/locales/en.js +110 -0
  141. package/package.json +65 -4
@@ -0,0 +1,307 @@
1
+ import { isObject } from "./json.js";
2
+ import { M, UNSTABLE } from "./m.js";
3
+ import { splitStateKey, toClientEvent } from "./events.js";
4
+ import { getState, powerLevels, userLevel } from "./authrules.js";
5
+ import { globMatch, globSearchWords } from "./glob.js";
6
+ import { KINDS, loadRules } from "./pushrules.js";
7
+ import { isSubscribed } from "../api/threadsubs.js";
8
+ //#region src/core/push.ts
9
+ /** Look up a dotted path; `\.` escapes a literal dot in a key (e.g. `content.m\.relates_to`). */
10
+ function valueAtPath(obj, path) {
11
+ const parts = path.split(/(?<!\\)\./).map((p) => p.replace(/\\\./g, ".").replace(/\\\\/g, "\\"));
12
+ let cur = obj;
13
+ for (const p of parts) {
14
+ if (!isObject(cur) || !(p in cur)) return void 0;
15
+ cur = cur[p];
16
+ }
17
+ return cur;
18
+ }
19
+ function conditionMatches(cond, ctx) {
20
+ switch (cond.kind) {
21
+ case "event_match": {
22
+ if (typeof cond.key !== "string" || typeof cond.pattern !== "string") return false;
23
+ const value = valueAtPath(ctx.event, cond.key);
24
+ if (typeof value !== "string") return false;
25
+ return cond.key === "content.body" ? globSearchWords(cond.pattern, value) : globMatch(cond.pattern, value, { caseInsensitive: true });
26
+ }
27
+ case "event_property_is": return typeof cond.key === "string" && valueAtPath(ctx.event, cond.key) === cond.value;
28
+ case "event_property_contains": {
29
+ const value = typeof cond.key === "string" ? valueAtPath(ctx.event, cond.key) : void 0;
30
+ return Array.isArray(value) && value.includes(cond.value);
31
+ }
32
+ case "contains_display_name": {
33
+ const body = ctx.event.content?.body;
34
+ if (!ctx.displayName || typeof body !== "string") return false;
35
+ const escaped = ctx.displayName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
36
+ return new RegExp(`(^|\\W)${escaped}(\\W|$)`, "i").test(body);
37
+ }
38
+ case "room_member_count": {
39
+ const m = /^(==|<=|>=|<|>)?(\d+)$/.exec(String(cond.is ?? ""));
40
+ if (!m) return false;
41
+ const n = Number(m[2]);
42
+ switch (m[1] ?? "==") {
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
+ case ">=": return ctx.memberCount >= n;
48
+ }
49
+ return false;
50
+ }
51
+ case UNSTABLE.IO.ELEMENT.MSC4306.THREAD_SUBSCRIPTION: return ctx.threadSubscribed !== void 0 && ctx.threadSubscribed !== null && ctx.threadSubscribed === cond.subscribed;
52
+ case "sender_notification_permission": return typeof cond.key === "string" && ctx.senderLevel >= (ctx.notificationLevels[cond.key] ?? 50);
53
+ default: return false;
54
+ }
55
+ }
56
+ function ruleMatches(kind, rule, ctx) {
57
+ if (!rule.enabled) return false;
58
+ switch (kind) {
59
+ case "override":
60
+ case "postcontent":
61
+ case "underride": return (rule.conditions ?? []).every((c) => conditionMatches(c, ctx));
62
+ case "content": return typeof rule.pattern === "string" && conditionMatches({
63
+ kind: "event_match",
64
+ key: "content.body",
65
+ pattern: rule.pattern
66
+ }, ctx);
67
+ case "room": return ctx.event.room_id === rule.rule_id;
68
+ case "sender": return ctx.event.sender === rule.rule_id;
69
+ }
70
+ return false;
71
+ }
72
+ /** Evaluate a user's push rules against an event; returns the actions of the first matching rule. */
73
+ function evaluateRules(rules, ctx) {
74
+ for (const kind of KINDS) for (const rule of rules[kind] ?? []) if (ruleMatches(kind, rule, ctx)) return rule.actions ?? [];
75
+ return null;
76
+ }
77
+ function tweaksOf(actions) {
78
+ const tweaks = {};
79
+ for (const a of actions) if (isObject(a) && typeof a.set_tweak === "string") tweaks[a.set_tweak] = "value" in a ? a.value : true;
80
+ return tweaks;
81
+ }
82
+ /** Thread an event belongs to for notification purposes ("main" for the main timeline). */
83
+ function threadIdOf(event) {
84
+ const rel = event.content?.[M.RELATES_TO];
85
+ return isObject(rel) && rel.rel_type === M.THREAD && typeof rel.event_id === "string" ? rel.event_id : "main";
86
+ }
87
+ /**
88
+ * Push rule evaluation and push gateway delivery. Every new event is evaluated for
89
+ * every local member; matches are stored for unread counts and `/notifications`.
90
+ */
91
+ var PushService = class {
92
+ hs;
93
+ constructor(hs) {
94
+ this.hs = hs;
95
+ }
96
+ async onEvent(ev, state, version) {
97
+ const event = ev.pdu;
98
+ const members = [];
99
+ for (const [key, member] of state) {
100
+ const [type, userId] = splitStateKey(key);
101
+ if (type !== M.ROOM.MEMBER) continue;
102
+ const m = member.content?.membership;
103
+ if (m === "join" || m === "invite" && event.type === M.ROOM.MEMBER && event.state_key === userId) members.push(userId);
104
+ }
105
+ const joined = members.filter((u) => getState(state, M.ROOM.MEMBER, u)?.content?.membership === "join");
106
+ const pl = powerLevels(state, version);
107
+ const senderLevel = userLevel(pl, event.sender);
108
+ const thread = threadIdOf(event);
109
+ for (const userId of members) {
110
+ if (userId === event.sender || !this.hs.isLocalUser(userId)) continue;
111
+ const actions = evaluateRules(await loadRules(this.hs, userId), {
112
+ event,
113
+ userId,
114
+ displayName: getState(state, M.ROOM.MEMBER, userId)?.content?.displayname ?? null,
115
+ memberCount: joined.length,
116
+ senderLevel: Number.isFinite(senderLevel) ? senderLevel : Number.MAX_SAFE_INTEGER,
117
+ notificationLevels: pl.notifications,
118
+ threadSubscribed: thread === "main" ? void 0 : await isSubscribed(this.hs, userId, event.room_id, thread)
119
+ });
120
+ if (!actions || !actions.includes("notify")) continue;
121
+ const tweaks = tweaksOf(actions);
122
+ await this.hs.db.upsert("notifications", {
123
+ user_id: userId,
124
+ room_id: event.room_id,
125
+ event_id: event.event_id,
126
+ stream: ev.stream,
127
+ highlight: tweaks.highlight === true,
128
+ thread_id: threadIdOf(event),
129
+ actions,
130
+ ts: Date.now()
131
+ });
132
+ this.deliver(userId, ev, state, tweaks).catch((err) => this.hs.logger.warn("Push delivery failed", err));
133
+ }
134
+ }
135
+ /** Unread notification counts since the user's read receipts. */
136
+ /** Stream positions up to which the user has read the room (unthreaded) and each thread. */
137
+ async readPositions(userId, roomId) {
138
+ const receipts = await this.hs.db.findMany("receipts", { where: [{
139
+ field: "room_id",
140
+ value: roomId
141
+ }, {
142
+ field: "user_id",
143
+ value: userId
144
+ }] });
145
+ let unthreaded = 0;
146
+ const threadRead = /* @__PURE__ */ new Map();
147
+ for (const r of receipts) {
148
+ if (r.receipt_type !== M.READ._ && r.receipt_type !== M.READ.PRIVATE) continue;
149
+ const target = await this.hs.rooms.getEvent(r.event_id);
150
+ if (!target) continue;
151
+ if (r.thread_id === "") unthreaded = Math.max(unthreaded, target.stream);
152
+ else threadRead.set(r.thread_id, Math.max(threadRead.get(r.thread_id) ?? 0, target.stream));
153
+ }
154
+ return {
155
+ unthreaded,
156
+ threads: threadRead
157
+ };
158
+ }
159
+ isRead(row, read) {
160
+ return row.stream <= read.unthreaded || row.stream <= (read.threads.get(row.thread_id) ?? 0);
161
+ }
162
+ async unreadCounts(userId, roomId) {
163
+ const read = await this.readPositions(userId, roomId);
164
+ const unthreaded = read.unthreaded;
165
+ const rows = await this.hs.db.findMany("notifications", { where: [
166
+ {
167
+ field: "user_id",
168
+ value: userId
169
+ },
170
+ {
171
+ field: "room_id",
172
+ value: roomId
173
+ },
174
+ {
175
+ field: "stream",
176
+ operator: "gt",
177
+ value: unthreaded
178
+ }
179
+ ] });
180
+ const room = {
181
+ notification_count: 0,
182
+ highlight_count: 0
183
+ };
184
+ const threads = {};
185
+ for (const n of rows) {
186
+ if (this.isRead(n, read)) continue;
187
+ const bucket = n.thread_id === "main" ? room : threads[n.thread_id] ??= {
188
+ notification_count: 0,
189
+ highlight_count: 0
190
+ };
191
+ bucket.notification_count++;
192
+ if (n.highlight) bucket.highlight_count++;
193
+ }
194
+ return {
195
+ room,
196
+ threads
197
+ };
198
+ }
199
+ async deliver(userId, ev, state, tweaks) {
200
+ const pushers = await this.hs.db.findMany("pushers", { where: [{
201
+ field: "user_id",
202
+ value: userId
203
+ }] });
204
+ if (pushers.length === 0) return;
205
+ const event = ev.pdu;
206
+ const unread = (await this.unreadCounts(userId, event.room_id)).room.notification_count;
207
+ for (const row of pushers) {
208
+ const pusher = row.pusher;
209
+ if (pusher.kind !== "http" || typeof pusher.data?.url !== "string") continue;
210
+ const { url, format, ...data } = pusher.data;
211
+ const notification = {
212
+ event_id: event.event_id,
213
+ room_id: event.room_id,
214
+ counts: { unread },
215
+ devices: [{
216
+ app_id: pusher.app_id,
217
+ pushkey: pusher.pushkey,
218
+ pushkey_ts: Math.floor(Date.now() / 1e3),
219
+ data,
220
+ tweaks
221
+ }],
222
+ prio: tweaks.highlight || tweaks.sound ? "high" : "low"
223
+ };
224
+ if (format !== "event_id_only") {
225
+ notification.type = event.type;
226
+ notification.sender = event.sender;
227
+ notification.content = event.content;
228
+ const name = getState(state, M.ROOM.NAME)?.content?.name;
229
+ if (typeof name === "string") notification.room_name = name;
230
+ const senderName = getState(state, M.ROOM.MEMBER, event.sender)?.content?.displayname;
231
+ if (typeof senderName === "string") notification.sender_display_name = senderName;
232
+ if (event.type === M.ROOM.MEMBER) {
233
+ notification.membership = event.content?.membership;
234
+ notification.user_is_target = event.state_key === userId;
235
+ }
236
+ }
237
+ const res = await this.hs.fetch(url, {
238
+ method: "POST",
239
+ headers: { "content-type": "application/json" },
240
+ body: JSON.stringify({ notification }),
241
+ signal: AbortSignal.timeout(1e4)
242
+ });
243
+ if (!res.ok) {
244
+ this.hs.logger.warn("Push gateway returned", res.status, "for", url);
245
+ continue;
246
+ }
247
+ const body = await res.json().catch(() => ({}));
248
+ if (Array.isArray(body.rejected) && body.rejected.includes(pusher.pushkey)) await this.hs.db.delete("pushers", [
249
+ {
250
+ field: "user_id",
251
+ value: userId
252
+ },
253
+ {
254
+ field: "app_id",
255
+ value: pusher.app_id
256
+ },
257
+ {
258
+ field: "pushkey",
259
+ value: pusher.pushkey
260
+ }
261
+ ]);
262
+ }
263
+ }
264
+ /** Rows for `GET /notifications`. */
265
+ async list(userId, opts) {
266
+ const where = [{
267
+ field: "user_id",
268
+ value: userId
269
+ }];
270
+ if (opts.from !== null) where.push({
271
+ field: "stream",
272
+ operator: "lt",
273
+ value: opts.from
274
+ });
275
+ if (opts.onlyHighlight) where.push({
276
+ field: "highlight",
277
+ value: true
278
+ });
279
+ const rows = await this.hs.db.findMany("notifications", {
280
+ where,
281
+ sortBy: {
282
+ field: "stream",
283
+ direction: "desc"
284
+ },
285
+ limit: opts.limit
286
+ });
287
+ const out = [];
288
+ const readByRoom = /* @__PURE__ */ new Map();
289
+ for (const row of rows) {
290
+ const ev = await this.hs.rooms.getEvent(row.event_id);
291
+ if (!ev) continue;
292
+ let read = readByRoom.get(row.room_id);
293
+ if (!read) readByRoom.set(row.room_id, read = await this.readPositions(userId, row.room_id));
294
+ out.push({
295
+ actions: row.actions,
296
+ event: toClientEvent(ev.pdu),
297
+ room_id: row.room_id,
298
+ ts: row.ts,
299
+ read: this.isRead(row, read),
300
+ stream: row.stream
301
+ });
302
+ }
303
+ return out;
304
+ }
305
+ };
306
+ //#endregion
307
+ export { PushService, evaluateRules, threadIdOf, tweaksOf, valueAtPath };
@@ -0,0 +1,229 @@
1
+ import { isObject } from "./json.js";
2
+ import { M, UNSTABLE } from "./m.js";
3
+ //#region src/core/pushrules.ts
4
+ const KINDS = [
5
+ "override",
6
+ "content",
7
+ "postcontent",
8
+ "room",
9
+ "sender",
10
+ "underride"
11
+ ];
12
+ function defaultRules(userId) {
13
+ const localpart = userId.slice(1).split(":")[0];
14
+ const notify = ["notify"];
15
+ const sound = (s) => ({
16
+ set_tweak: "sound",
17
+ value: s
18
+ });
19
+ const highlight = { set_tweak: "highlight" };
20
+ const r = (rule_id, conditions, actions, extra = {}) => ({
21
+ rule_id,
22
+ default: true,
23
+ enabled: true,
24
+ actions,
25
+ ...conditions ? { conditions } : {},
26
+ ...extra
27
+ });
28
+ const em = (key, pattern) => ({
29
+ kind: "event_match",
30
+ key,
31
+ pattern
32
+ });
33
+ const ep = (key, value) => ({
34
+ kind: "event_property_is",
35
+ key,
36
+ value
37
+ });
38
+ return {
39
+ override: [
40
+ r(M.RULE.MASTER, [], [], { enabled: false }),
41
+ r(M.RULE.SUPPRESS_NOTICES, [em("content.msgtype", M.NOTICE)], []),
42
+ r(M.RULE.INVITE_FOR_ME, [
43
+ em("type", M.ROOM.MEMBER),
44
+ em("content.membership", "invite"),
45
+ em("state_key", userId)
46
+ ], [...notify, sound("default")]),
47
+ r(M.RULE.MEMBER_EVENT, [em("type", M.ROOM.MEMBER)], []),
48
+ r(M.RULE.IS_USER_MENTION, [{
49
+ kind: "event_property_contains",
50
+ key: "content.m\\.mentions.user_ids",
51
+ value: userId
52
+ }], [
53
+ ...notify,
54
+ sound("default"),
55
+ highlight
56
+ ]),
57
+ r(M.RULE.CONTAINS_DISPLAY_NAME, [{ kind: "contains_display_name" }], [
58
+ ...notify,
59
+ sound("default"),
60
+ highlight
61
+ ]),
62
+ r(M.RULE.IS_ROOM_MENTION, [ep("content.m\\.mentions.room", true), {
63
+ kind: "sender_notification_permission",
64
+ key: "room"
65
+ }], [...notify, highlight]),
66
+ r(M.RULE.ROOMNOTIF, [em("content.body", "@room"), {
67
+ kind: "sender_notification_permission",
68
+ key: "room"
69
+ }], [...notify, highlight]),
70
+ r(M.RULE.TOMBSTONE, [em("type", M.ROOM.TOMBSTONE), em("state_key", "")], [...notify, highlight]),
71
+ r(M.RULE.REACTION, [em("type", M.REACTION)], []),
72
+ r(M.RULE.ROOM.SERVER_ACL, [em("type", M.ROOM.SERVER_ACL), em("state_key", "")], []),
73
+ r(M.RULE.SUPPRESS_EDITS, [ep("content.m\\.relates_to.rel_type", M.REPLACE)], []),
74
+ r(UNSTABLE.ORG.MATRIX.MSC3930.RULE.POLL_RESPONSE, [em("type", UNSTABLE.ORG.MATRIX.MSC3381.POLL.RESPONSE)], [])
75
+ ],
76
+ content: [r(M.RULE.CONTAINS_USER_NAME, void 0, [
77
+ ...notify,
78
+ sound("default"),
79
+ highlight
80
+ ], { pattern: localpart })],
81
+ postcontent: [r(UNSTABLE.IO.ELEMENT.MSC4306.RULE.UNSUBSCRIBED_THREAD, [{
82
+ kind: UNSTABLE.IO.ELEMENT.MSC4306.THREAD_SUBSCRIPTION,
83
+ subscribed: false
84
+ }], []), r(UNSTABLE.IO.ELEMENT.MSC4306.RULE.SUBSCRIBED_THREAD, [{
85
+ kind: UNSTABLE.IO.ELEMENT.MSC4306.THREAD_SUBSCRIPTION,
86
+ subscribed: true
87
+ }], [...notify, sound("default")])],
88
+ room: [],
89
+ sender: [],
90
+ underride: [
91
+ r(M.RULE.CALL, [em("type", M.CALL.INVITE)], [...notify, sound("ring")]),
92
+ r(M.RULE.ENCRYPTED_ROOM_ONE_TO_ONE, [{
93
+ kind: "room_member_count",
94
+ is: "2"
95
+ }, em("type", M.ROOM.ENCRYPTED)], [...notify, sound("default")]),
96
+ r(M.RULE.ROOM_ONE_TO_ONE, [{
97
+ kind: "room_member_count",
98
+ is: "2"
99
+ }, em("type", M.ROOM.MESSAGE)], [...notify, sound("default")]),
100
+ r(M.RULE.MESSAGE, [em("type", M.ROOM.MESSAGE)], notify),
101
+ r(UNSTABLE.ORG.MATRIX.MSC3930.RULE.POLL_START_ONE_TO_ONE, [{
102
+ kind: "room_member_count",
103
+ is: "2"
104
+ }, em("type", UNSTABLE.ORG.MATRIX.MSC3381.POLL.START)], [...notify, sound("default")]),
105
+ r(UNSTABLE.ORG.MATRIX.MSC3930.RULE.POLL_END_ONE_TO_ONE, [{
106
+ kind: "room_member_count",
107
+ is: "2"
108
+ }, em("type", UNSTABLE.ORG.MATRIX.MSC3381.POLL.END)], [...notify, sound("default")]),
109
+ r(UNSTABLE.ORG.MATRIX.MSC3930.RULE.POLL_START, [em("type", UNSTABLE.ORG.MATRIX.MSC3381.POLL.START)], notify),
110
+ r(UNSTABLE.ORG.MATRIX.MSC3930.RULE.POLL_END, [em("type", UNSTABLE.ORG.MATRIX.MSC3381.POLL.END)], notify),
111
+ r(M.RULE.ENCRYPTED, [em("type", M.ROOM.ENCRYPTED)], notify)
112
+ ]
113
+ };
114
+ }
115
+ /** Serialise read-modify-write cycles on a user's push rules (cluster-wide). */
116
+ function withRulesLock(hs, userId, fn) {
117
+ return hs.cluster.lock(`pushrules:${userId}`, () => {
118
+ cacheFor(hs).delete(userId);
119
+ return fn();
120
+ });
121
+ }
122
+ const rulesCache = /* @__PURE__ */ new WeakMap();
123
+ function cacheFor(hs) {
124
+ let c = rulesCache.get(hs);
125
+ if (!c) {
126
+ const cache = /* @__PURE__ */ new Map();
127
+ rulesCache.set(hs, c = cache);
128
+ hs.cluster.subscribe("pushrules", (msg) => {
129
+ if (msg?.from !== hs.instanceId) cache.delete(msg.userId);
130
+ });
131
+ }
132
+ return c;
133
+ }
134
+ async function loadRules(hs, userId) {
135
+ const cached = cacheFor(hs).get(userId);
136
+ if (cached) return structuredClone(cached);
137
+ const rules = await readRules(hs, userId);
138
+ const cache = cacheFor(hs);
139
+ if (cache.size > 5e3) cache.clear();
140
+ cache.set(userId, rules);
141
+ return structuredClone(rules);
142
+ }
143
+ async function readRules(hs, userId) {
144
+ const row = await hs.db.findOne("account_data", [
145
+ {
146
+ field: "user_id",
147
+ value: userId
148
+ },
149
+ {
150
+ field: "room_id",
151
+ value: ""
152
+ },
153
+ {
154
+ field: "type",
155
+ value: M.PUSH_RULES
156
+ }
157
+ ]);
158
+ if (!isObject(row?.content?.global)) return defaultRules(userId);
159
+ const rules = row.content.global;
160
+ const defaults = defaultRules(userId);
161
+ for (const kind of KINDS) if (!Array.isArray(rules[kind])) rules[kind] = defaults[kind];
162
+ return rules;
163
+ }
164
+ async function saveRules(hs, userId, rules) {
165
+ cacheFor(hs).set(userId, structuredClone(rules));
166
+ hs.cluster.publish("pushrules", {
167
+ from: hs.instanceId,
168
+ userId
169
+ }).catch(() => {});
170
+ await hs.streams.write((stream) => hs.db.upsert("account_data", {
171
+ user_id: userId,
172
+ room_id: "",
173
+ type: M.PUSH_RULES,
174
+ content: { global: rules },
175
+ stream
176
+ }));
177
+ hs.notifier.notify([userId]);
178
+ }
179
+ /** Copy room-specific push rules from an upgraded room's predecessor to the new room. */
180
+ function copyRoomPushRules(hs, userId, oldRoomId, newRoomId) {
181
+ return withRulesLock(hs, userId, () => copyRules(hs, userId, oldRoomId, newRoomId));
182
+ }
183
+ async function copyRules(hs, userId, oldRoomId, newRoomId) {
184
+ const row = await hs.db.findOne("account_data", [
185
+ {
186
+ field: "user_id",
187
+ value: userId
188
+ },
189
+ {
190
+ field: "room_id",
191
+ value: ""
192
+ },
193
+ {
194
+ field: "type",
195
+ value: M.PUSH_RULES
196
+ }
197
+ ]);
198
+ if (!isObject(row?.content?.global)) return;
199
+ const rules = row.content.global;
200
+ let changed = false;
201
+ for (const kind of KINDS) {
202
+ const list = rules[kind] ?? [];
203
+ const additions = [];
204
+ for (const rule of list) {
205
+ if (rule.default) continue;
206
+ if (kind === "room" && rule.rule_id === oldRoomId) {
207
+ if (!list.some((r) => r.rule_id === newRoomId)) additions.push({
208
+ ...structuredClone(rule),
209
+ rule_id: newRoomId
210
+ });
211
+ } else if (Array.isArray(rule.conditions) && rule.conditions.some((c) => c?.key === "room_id" && c?.pattern === oldRoomId)) {
212
+ const copy = structuredClone(rule);
213
+ copy.rule_id = `${rule.rule_id}-${newRoomId}`;
214
+ copy.conditions = copy.conditions.map((c) => c?.key === "room_id" && c?.pattern === oldRoomId ? {
215
+ ...c,
216
+ pattern: newRoomId
217
+ } : c);
218
+ if (!list.some((r) => r.rule_id === copy.rule_id)) additions.push(copy);
219
+ }
220
+ }
221
+ if (additions.length) {
222
+ rules[kind] = [...additions, ...list];
223
+ changed = true;
224
+ }
225
+ }
226
+ if (changed) await saveRules(hs, userId, rules);
227
+ }
228
+ //#endregion
229
+ 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 };