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.
- package/LICENSE +75 -0
- package/README.md +479 -2
- package/dist/adapters/auth.d.ts +50 -0
- package/dist/adapters/auth.js +18 -0
- package/dist/adapters/cluster.d.ts +55 -0
- package/dist/adapters/cluster.js +249 -0
- package/dist/adapters/media.d.ts +50 -0
- package/dist/adapters/media.js +63 -0
- package/dist/adapters/s3.d.ts +33 -0
- package/dist/adapters/s3.js +95 -0
- package/dist/api/account.js +594 -0
- package/dist/api/admin.js +267 -0
- package/dist/api/appservice.js +45 -0
- package/dist/api/backup.js +280 -0
- package/dist/api/federation.js +826 -0
- package/dist/api/filters.js +88 -0
- package/dist/api/index.js +47 -0
- package/dist/api/legacy.js +183 -0
- package/dist/api/media.js +271 -0
- package/dist/api/misc.js +95 -0
- package/dist/api/preview.js +201 -0
- package/dist/api/profile.js +176 -0
- package/dist/api/push.js +202 -0
- package/dist/api/relationships.js +270 -0
- package/dist/api/rooms.js +1487 -0
- package/dist/api/rtc.d.ts +23 -0
- package/dist/api/rtc.js +66 -0
- package/dist/api/search.js +161 -0
- package/dist/api/slidingsync.js +474 -0
- package/dist/api/spaces.js +177 -0
- package/dist/api/sync.js +609 -0
- package/dist/api/threadsubs.js +140 -0
- package/dist/api/threepid.js +155 -0
- package/dist/api/uia.js +127 -0
- package/dist/api/user.js +329 -0
- package/dist/core/admin.d.ts +84 -0
- package/dist/core/admin.js +308 -0
- package/dist/core/adminroom.d.ts +49 -0
- package/dist/core/adminroom.js +483 -0
- package/dist/core/appservices.d.ts +74 -0
- package/dist/core/appservices.js +369 -0
- package/dist/core/audit.d.ts +50 -0
- package/dist/core/audit.js +86 -0
- package/dist/core/auth.d.ts +48 -0
- package/dist/core/auth.js +237 -0
- package/dist/core/authrules.d.ts +4 -0
- package/dist/core/authrules.js +348 -0
- package/dist/core/cluster.d.ts +66 -0
- package/dist/core/cluster.js +191 -0
- package/dist/core/crypto.d.ts +10 -0
- package/dist/core/crypto.js +100 -0
- package/dist/core/delayed.d.ts +39 -0
- package/dist/core/delayed.js +160 -0
- package/dist/core/errors.d.ts +9 -0
- package/dist/core/errors.js +45 -0
- package/dist/core/events.d.ts +32 -0
- package/dist/core/events.js +186 -0
- package/dist/core/glob.js +66 -0
- package/dist/core/identity.d.ts +24 -0
- package/dist/core/identity.js +96 -0
- package/dist/core/ids.js +36 -0
- package/dist/core/invitefilter.js +36 -0
- package/dist/core/json.js +16 -0
- package/dist/core/keys.d.ts +36 -0
- package/dist/core/keys.js +450 -0
- package/dist/core/m.d.ts +209 -0
- package/dist/core/m.js +182 -0
- package/dist/core/notifier.d.ts +17 -0
- package/dist/core/notifier.js +54 -0
- package/dist/core/oauth.d.ts +38 -0
- package/dist/core/oauth.js +108 -0
- package/dist/core/partialstate.d.ts +32 -0
- package/dist/core/partialstate.js +198 -0
- package/dist/core/phone.js +268 -0
- package/dist/core/policy.d.ts +29 -0
- package/dist/core/policy.js +97 -0
- package/dist/core/profile.js +39 -0
- package/dist/core/push.d.ts +46 -0
- package/dist/core/push.js +307 -0
- package/dist/core/pushrules.js +229 -0
- package/dist/core/ratelimit.d.ts +23 -0
- package/dist/core/ratelimit.js +77 -0
- package/dist/core/rooms.d.ts +215 -0
- package/dist/core/rooms.js +1731 -0
- package/dist/core/settings.d.ts +11 -0
- package/dist/core/settings.js +29 -0
- package/dist/core/sso.d.ts +46 -0
- package/dist/core/sso.js +165 -0
- package/dist/core/stateres.d.ts +4 -0
- package/dist/core/stateres.js +209 -0
- package/dist/core/threepid.d.ts +80 -0
- package/dist/core/threepid.js +275 -0
- package/dist/core/typing.d.ts +16 -0
- package/dist/core/typing.js +60 -0
- package/dist/federation/client.d.ts +51 -0
- package/dist/federation/client.js +279 -0
- package/dist/federation/index.d.ts +153 -0
- package/dist/federation/index.js +1059 -0
- package/dist/federation/keyring.d.ts +26 -0
- package/dist/federation/keyring.js +122 -0
- package/dist/federation/sender.d.ts +26 -0
- package/dist/federation/sender.js +164 -0
- package/dist/homeserver.d.ts +224 -0
- package/dist/homeserver.js +302 -0
- package/dist/http/adapters.d.ts +40 -0
- package/dist/http/adapters.js +130 -0
- package/dist/http/handler.d.ts +8 -0
- package/dist/http/handler.js +181 -0
- package/dist/http/input.js +24 -0
- package/dist/http/procedure.js +25 -0
- package/dist/http/router.d.ts +43 -0
- package/dist/http/router.js +56 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +15 -0
- package/dist/storage/memory.d.ts +5 -0
- package/dist/storage/memory.js +122 -0
- package/dist/storage/schema.d.ts +4 -0
- package/dist/storage/schema.js +664 -0
- package/dist/storage/sql.d.ts +72 -0
- package/dist/storage/sql.js +274 -0
- package/dist/storage/types.d.ts +55 -0
- package/dist/vendor/zod/LICENSE +21 -0
- package/dist/vendor/zod/v4/classic/errors.js +54 -0
- package/dist/vendor/zod/v4/classic/parse.js +17 -0
- package/dist/vendor/zod/v4/classic/schemas.js +865 -0
- package/dist/vendor/zod/v4/core/api.js +512 -0
- package/dist/vendor/zod/v4/core/checks.js +300 -0
- package/dist/vendor/zod/v4/core/core.js +109 -0
- package/dist/vendor/zod/v4/core/doc.js +35 -0
- package/dist/vendor/zod/v4/core/errors.js +118 -0
- package/dist/vendor/zod/v4/core/json-schema-processors.js +428 -0
- package/dist/vendor/zod/v4/core/memoizer.js +255 -0
- package/dist/vendor/zod/v4/core/parse.js +206 -0
- package/dist/vendor/zod/v4/core/regexes.js +73 -0
- package/dist/vendor/zod/v4/core/registries.js +48 -0
- package/dist/vendor/zod/v4/core/schemas.js +1378 -0
- package/dist/vendor/zod/v4/core/to-json-schema.js +448 -0
- package/dist/vendor/zod/v4/core/util.js +537 -0
- package/dist/vendor/zod/v4/core/versions.js +8 -0
- package/dist/vendor/zod/v4/locales/en.js +110 -0
- package/package.json +65 -4
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { randomToken } from "./crypto.js";
|
|
2
|
+
import { errors } from "./errors.js";
|
|
3
|
+
import { generateDeviceId } from "./ids.js";
|
|
4
|
+
//#region src/core/auth.ts
|
|
5
|
+
var AuthService = class {
|
|
6
|
+
hs;
|
|
7
|
+
cache = /* @__PURE__ */ new Map();
|
|
8
|
+
constructor(hs) {
|
|
9
|
+
this.hs = hs;
|
|
10
|
+
hs.cluster.subscribe("auth", (msg) => {
|
|
11
|
+
if (msg?.from === hs.instanceId) return;
|
|
12
|
+
if (msg.token) this.cache.delete(msg.token);
|
|
13
|
+
if (msg.userId) {
|
|
14
|
+
for (const [t, s] of this.cache) if (s.userId === msg.userId) this.cache.delete(t);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async authenticate(token, ip) {
|
|
19
|
+
const cached = this.cache.get(token);
|
|
20
|
+
if (cached) return cached;
|
|
21
|
+
const row = await this.hs.db.findOne("access_tokens", [{
|
|
22
|
+
field: "token",
|
|
23
|
+
value: token
|
|
24
|
+
}]);
|
|
25
|
+
if (!row) {
|
|
26
|
+
if (this.hs.oauth) return this.hs.oauth.authenticate(token);
|
|
27
|
+
throw errors.unknownToken();
|
|
28
|
+
}
|
|
29
|
+
if (row.expires_ts !== null && row.expires_ts < Date.now()) throw errors.unknownToken("Access token expired", true);
|
|
30
|
+
const user = await this.hs.db.findOne("users", [{
|
|
31
|
+
field: "user_id",
|
|
32
|
+
value: row.user_id
|
|
33
|
+
}]);
|
|
34
|
+
if (!user || user.deactivated) throw errors.unknownToken();
|
|
35
|
+
const session = {
|
|
36
|
+
userId: row.user_id,
|
|
37
|
+
deviceId: row.device_id,
|
|
38
|
+
accessToken: token,
|
|
39
|
+
isGuest: !!user.is_guest,
|
|
40
|
+
isAdmin: !!user.is_admin,
|
|
41
|
+
locked: !!user.locked,
|
|
42
|
+
suspended: !!user.suspended
|
|
43
|
+
};
|
|
44
|
+
if (row.expires_ts === null) this.cache.set(token, session);
|
|
45
|
+
await this.hs.db.update("devices", [{
|
|
46
|
+
field: "user_id",
|
|
47
|
+
value: row.user_id
|
|
48
|
+
}, {
|
|
49
|
+
field: "device_id",
|
|
50
|
+
value: row.device_id
|
|
51
|
+
}], {
|
|
52
|
+
last_seen_ts: Date.now(),
|
|
53
|
+
last_seen_ip: ip
|
|
54
|
+
});
|
|
55
|
+
return session;
|
|
56
|
+
}
|
|
57
|
+
/** Create (or reuse) a device and issue a fresh access token for it. */
|
|
58
|
+
async createSession(userId, opts = {}) {
|
|
59
|
+
if ((await this.hs.db.findOne("users", [{
|
|
60
|
+
field: "user_id",
|
|
61
|
+
value: userId
|
|
62
|
+
}]))?.locked) throw errors.userLocked();
|
|
63
|
+
const deviceId = opts.deviceId || generateDeviceId();
|
|
64
|
+
if (await this.hs.db.findOne("devices", [{
|
|
65
|
+
field: "user_id",
|
|
66
|
+
value: userId
|
|
67
|
+
}, {
|
|
68
|
+
field: "device_id",
|
|
69
|
+
value: deviceId
|
|
70
|
+
}])) {
|
|
71
|
+
await this.revokeDeviceTokens(userId, deviceId);
|
|
72
|
+
if (opts.displayName) await this.hs.db.update("devices", [{
|
|
73
|
+
field: "user_id",
|
|
74
|
+
value: userId
|
|
75
|
+
}, {
|
|
76
|
+
field: "device_id",
|
|
77
|
+
value: deviceId
|
|
78
|
+
}], { display_name: opts.displayName });
|
|
79
|
+
} else {
|
|
80
|
+
await this.hs.db.create("devices", {
|
|
81
|
+
user_id: userId,
|
|
82
|
+
device_id: deviceId,
|
|
83
|
+
display_name: opts.displayName ?? null,
|
|
84
|
+
last_seen_ts: Date.now(),
|
|
85
|
+
last_seen_ip: null
|
|
86
|
+
});
|
|
87
|
+
await this.hs.keys.deviceChanged(userId);
|
|
88
|
+
await this.hs.audit.record({
|
|
89
|
+
action: "device_create",
|
|
90
|
+
actor: userId,
|
|
91
|
+
target: deviceId
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return this.issueToken(userId, deviceId, !!opts.refreshToken);
|
|
95
|
+
}
|
|
96
|
+
async issueToken(userId, deviceId, withRefresh) {
|
|
97
|
+
const token = `mat_${randomToken()}`;
|
|
98
|
+
const lifetime = this.hs.config.accessTokenLifetimeMs;
|
|
99
|
+
const refresh = withRefresh ? `mar_${randomToken()}` : null;
|
|
100
|
+
await this.hs.db.create("access_tokens", {
|
|
101
|
+
token,
|
|
102
|
+
user_id: userId,
|
|
103
|
+
device_id: deviceId,
|
|
104
|
+
refresh_token: refresh,
|
|
105
|
+
expires_ts: refresh && lifetime ? Date.now() + lifetime : null
|
|
106
|
+
});
|
|
107
|
+
const out = {
|
|
108
|
+
user_id: userId,
|
|
109
|
+
device_id: deviceId,
|
|
110
|
+
access_token: token
|
|
111
|
+
};
|
|
112
|
+
if (refresh) {
|
|
113
|
+
out.refresh_token = refresh;
|
|
114
|
+
if (lifetime) out.expires_in_ms = lifetime;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
async refresh(refreshToken) {
|
|
119
|
+
const row = await this.hs.db.findOne("access_tokens", [{
|
|
120
|
+
field: "refresh_token",
|
|
121
|
+
value: refreshToken
|
|
122
|
+
}]);
|
|
123
|
+
if (!row) throw errors.unknownToken("Unknown refresh token");
|
|
124
|
+
await this.hs.db.delete("access_tokens", [{
|
|
125
|
+
field: "token",
|
|
126
|
+
value: row.token
|
|
127
|
+
}]);
|
|
128
|
+
this.cache.delete(row.token);
|
|
129
|
+
return this.issueToken(row.user_id, row.device_id, true);
|
|
130
|
+
}
|
|
131
|
+
/** Drop cached sessions of a user (after admin/deactivation changes). */
|
|
132
|
+
async invalidateUser(userId) {
|
|
133
|
+
for (const [token, s] of this.cache) if (s.userId === userId) this.cache.delete(token);
|
|
134
|
+
await this.hs.cluster.publish("auth", {
|
|
135
|
+
from: this.hs.instanceId,
|
|
136
|
+
userId
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/** Issue a short-lived single-use `m.login.token` for a user (SSO, or host-app sign-in). */
|
|
140
|
+
async createLoginToken(userId, ttlMs = 12e4) {
|
|
141
|
+
const token = `mlt_${randomToken()}`;
|
|
142
|
+
await this.hs.db.create("login_tokens", {
|
|
143
|
+
token,
|
|
144
|
+
user_id: userId,
|
|
145
|
+
expires_ts: Date.now() + ttlMs
|
|
146
|
+
});
|
|
147
|
+
return token;
|
|
148
|
+
}
|
|
149
|
+
/** Redeem a login token; returns the user ID or throws. */
|
|
150
|
+
async consumeLoginToken(token) {
|
|
151
|
+
const row = await this.hs.db.findOne("login_tokens", [{
|
|
152
|
+
field: "token",
|
|
153
|
+
value: token
|
|
154
|
+
}]);
|
|
155
|
+
if (row) await this.hs.db.delete("login_tokens", [{
|
|
156
|
+
field: "token",
|
|
157
|
+
value: token
|
|
158
|
+
}]);
|
|
159
|
+
if (!row || row.expires_ts < Date.now()) throw errors.forbidden("Invalid or expired login token");
|
|
160
|
+
return row.user_id;
|
|
161
|
+
}
|
|
162
|
+
async revokeToken(token) {
|
|
163
|
+
this.hs.oauth?.forget(token);
|
|
164
|
+
await this.hs.cluster.publish("auth", {
|
|
165
|
+
from: this.hs.instanceId,
|
|
166
|
+
token
|
|
167
|
+
});
|
|
168
|
+
this.cache.delete(token);
|
|
169
|
+
await this.hs.db.delete("access_tokens", [{
|
|
170
|
+
field: "token",
|
|
171
|
+
value: token
|
|
172
|
+
}]);
|
|
173
|
+
}
|
|
174
|
+
async revokeDeviceTokens(userId, deviceId) {
|
|
175
|
+
const rows = await this.hs.db.findMany("access_tokens", { where: [{
|
|
176
|
+
field: "user_id",
|
|
177
|
+
value: userId
|
|
178
|
+
}] });
|
|
179
|
+
for (const row of rows) if (row.device_id === deviceId) await this.revokeToken(row.token);
|
|
180
|
+
}
|
|
181
|
+
async deleteDevice(userId, deviceId) {
|
|
182
|
+
await this.revokeDeviceTokens(userId, deviceId);
|
|
183
|
+
const where = [{
|
|
184
|
+
field: "user_id",
|
|
185
|
+
value: userId
|
|
186
|
+
}, {
|
|
187
|
+
field: "device_id",
|
|
188
|
+
value: deviceId
|
|
189
|
+
}];
|
|
190
|
+
await this.hs.db.delete("devices", where);
|
|
191
|
+
await this.hs.db.delete("device_keys", where);
|
|
192
|
+
await this.hs.db.delete("one_time_keys", where);
|
|
193
|
+
await this.hs.db.delete("fallback_keys", where);
|
|
194
|
+
await this.hs.db.delete("to_device", where);
|
|
195
|
+
await this.hs.db.delete("pushers", where);
|
|
196
|
+
await this.hs.keys.deviceChanged(userId, deviceId);
|
|
197
|
+
await this.hs.audit.record({
|
|
198
|
+
action: "device_delete",
|
|
199
|
+
actor: userId,
|
|
200
|
+
target: deviceId
|
|
201
|
+
});
|
|
202
|
+
await this.hs.db.delete("account_data", [
|
|
203
|
+
{
|
|
204
|
+
field: "user_id",
|
|
205
|
+
value: userId
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
field: "room_id",
|
|
209
|
+
value: ""
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
field: "type",
|
|
213
|
+
value: `org.matrix.msc3890.local_notification_settings.${deviceId}`
|
|
214
|
+
}
|
|
215
|
+
]);
|
|
216
|
+
await this.hs.keys.markDeviceListChanged(userId);
|
|
217
|
+
}
|
|
218
|
+
async deleteAllDevices(userId, except) {
|
|
219
|
+
const devices = await this.hs.db.findMany("devices", { where: [{
|
|
220
|
+
field: "user_id",
|
|
221
|
+
value: userId
|
|
222
|
+
}] });
|
|
223
|
+
for (const d of devices) if (d.device_id !== except) await this.deleteDevice(userId, d.device_id);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* Suspended accounts (spec "Account suspension") may read, leave, redact their own events, manage
|
|
228
|
+
* keys and devices, but not join, invite, send or change their profile. Rooms used to talk to the
|
|
229
|
+
* server administration (the admin room, server notices) stay writable.
|
|
230
|
+
*/
|
|
231
|
+
async function assertNotSuspended(hs, session, roomId) {
|
|
232
|
+
if (!session.suspended) return;
|
|
233
|
+
if (roomId && (roomId === await hs.adminRoom?.roomId() || await hs.admin.isNoticeRoom(roomId))) return;
|
|
234
|
+
throw errors.userSuspended();
|
|
235
|
+
}
|
|
236
|
+
//#endregion
|
|
237
|
+
export { AuthService, assertNotSuspended };
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { isObject } from "./json.js";
|
|
2
|
+
import { publicKeyFromBase64, verifyJsonSignature } from "./crypto.js";
|
|
3
|
+
import { M } from "./m.js";
|
|
4
|
+
import { roomVersionFeatures, stateKey, versionNum } from "./events.js";
|
|
5
|
+
import { errors } from "./errors.js";
|
|
6
|
+
import { isValidUserId } from "./ids.js";
|
|
7
|
+
//#region src/core/authrules.ts
|
|
8
|
+
/** MSC3757: the user owning a state key of the form "@user:server" or "@user:server_suffix". */
|
|
9
|
+
function ownerOfStateKey(stateKeyValue) {
|
|
10
|
+
const key = stateKeyValue;
|
|
11
|
+
if (!key.startsWith("@")) return null;
|
|
12
|
+
if (isValidUserId(stateKeyValue)) return stateKeyValue;
|
|
13
|
+
const colon = key.indexOf(":");
|
|
14
|
+
if (colon < 0) return null;
|
|
15
|
+
const underscore = key.indexOf("_", colon);
|
|
16
|
+
if (underscore < 0) return null;
|
|
17
|
+
const owner = key.slice(0, underscore);
|
|
18
|
+
return isValidUserId(owner) ? owner : null;
|
|
19
|
+
}
|
|
20
|
+
function getState(state, type, key = "") {
|
|
21
|
+
return state.get(stateKey(type, key));
|
|
22
|
+
}
|
|
23
|
+
function membershipOf(state, userId) {
|
|
24
|
+
return getState(state, M.ROOM.MEMBER, userId)?.content?.membership ?? "leave";
|
|
25
|
+
}
|
|
26
|
+
function roomCreator(state, roomVersion) {
|
|
27
|
+
const create = getState(state, M.ROOM.CREATE);
|
|
28
|
+
if (!create) return void 0;
|
|
29
|
+
return roomVersionFeatures(roomVersion).v11 ? create.sender : create.content.creator ?? create.sender;
|
|
30
|
+
}
|
|
31
|
+
const toInt = (v, fallback) => {
|
|
32
|
+
if (typeof v === "number" && Number.isFinite(v)) return Math.trunc(v);
|
|
33
|
+
if (typeof v === "string" && /^\s*[+-]?\d+\s*$/.test(v)) return Number.parseInt(v, 10);
|
|
34
|
+
return fallback;
|
|
35
|
+
};
|
|
36
|
+
/** v12+: the create event's sender plus `additional_creators`. Empty for older versions. */
|
|
37
|
+
function roomCreators(state, roomVersion) {
|
|
38
|
+
if (!roomVersionFeatures(roomVersion).v12) return /* @__PURE__ */ new Set();
|
|
39
|
+
const create = getState(state, M.ROOM.CREATE);
|
|
40
|
+
if (!create) return /* @__PURE__ */ new Set();
|
|
41
|
+
const extra = Array.isArray(create.content?.additional_creators) ? create.content.additional_creators : [];
|
|
42
|
+
return /* @__PURE__ */ new Set([create.sender, ...extra.filter((u) => typeof u === "string")]);
|
|
43
|
+
}
|
|
44
|
+
function powerLevels(state, roomVersion) {
|
|
45
|
+
const pl = getState(state, M.ROOM.POWER_LEVELS)?.content;
|
|
46
|
+
const creators = roomCreators(state, roomVersion);
|
|
47
|
+
if (!pl) {
|
|
48
|
+
const creator = roomCreator(state, roomVersion);
|
|
49
|
+
return {
|
|
50
|
+
creators,
|
|
51
|
+
users: creator ? { [creator]: 100 } : {},
|
|
52
|
+
users_default: 0,
|
|
53
|
+
events: {},
|
|
54
|
+
events_default: 0,
|
|
55
|
+
state_default: 0,
|
|
56
|
+
ban: 50,
|
|
57
|
+
kick: 50,
|
|
58
|
+
redact: 50,
|
|
59
|
+
invite: 0,
|
|
60
|
+
notifications: { room: 50 }
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const users = {};
|
|
64
|
+
if (isObject(pl.users)) for (const [u, v] of Object.entries(pl.users)) users[u] = toInt(v, 0);
|
|
65
|
+
const events = {};
|
|
66
|
+
if (isObject(pl.events)) for (const [e, v] of Object.entries(pl.events)) events[e] = toInt(v, 0);
|
|
67
|
+
const notifications = { room: 50 };
|
|
68
|
+
if (isObject(pl.notifications)) for (const [e, v] of Object.entries(pl.notifications)) notifications[e] = toInt(v, 50);
|
|
69
|
+
return {
|
|
70
|
+
creators,
|
|
71
|
+
users,
|
|
72
|
+
events,
|
|
73
|
+
notifications,
|
|
74
|
+
users_default: toInt(pl.users_default, 0),
|
|
75
|
+
events_default: toInt(pl.events_default, 0),
|
|
76
|
+
state_default: toInt(pl.state_default, 50),
|
|
77
|
+
ban: toInt(pl.ban, 50),
|
|
78
|
+
kick: toInt(pl.kick, 50),
|
|
79
|
+
redact: toInt(pl.redact, 50),
|
|
80
|
+
invite: toInt(pl.invite, 0)
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function userLevel(pl, userId) {
|
|
84
|
+
if (pl.creators.has(userId)) return Infinity;
|
|
85
|
+
return pl.users[userId] ?? pl.users_default;
|
|
86
|
+
}
|
|
87
|
+
function requiredLevel(pl, type, isState) {
|
|
88
|
+
return pl.events[type] ?? (isState ? pl.state_default : pl.events_default);
|
|
89
|
+
}
|
|
90
|
+
const sameDomain = (a, b) => a.slice(a.indexOf(":") + 1) === b.slice(b.indexOf(":") + 1);
|
|
91
|
+
const domainOf = (id) => id.slice(id.indexOf(":") + 1);
|
|
92
|
+
/** The (type, state_key) pairs an event's auth_events may reference (spec "auth events selection"). */
|
|
93
|
+
function authEventKeys(event, roomVersion) {
|
|
94
|
+
if (event.type === M.ROOM.CREATE) return [];
|
|
95
|
+
const keys = [stateKey(M.ROOM.POWER_LEVELS, ""), stateKey(M.ROOM.MEMBER, event.sender)];
|
|
96
|
+
if (!roomVersionFeatures(roomVersion).v12) keys.unshift(stateKey(M.ROOM.CREATE, ""));
|
|
97
|
+
if (event.type === M.ROOM.MEMBER && typeof event.state_key === "string") {
|
|
98
|
+
keys.push(stateKey(M.ROOM.MEMBER, event.state_key));
|
|
99
|
+
const membership = event.content?.membership;
|
|
100
|
+
if ([
|
|
101
|
+
"join",
|
|
102
|
+
"invite",
|
|
103
|
+
"knock"
|
|
104
|
+
].includes(membership)) keys.push(stateKey(M.ROOM.JOIN_RULES, ""));
|
|
105
|
+
const token = event.content?.third_party_invite?.signed?.token;
|
|
106
|
+
if (membership === "invite" && typeof token === "string") keys.push(stateKey(M.ROOM.THIRD_PARTY_INVITE, token));
|
|
107
|
+
const via = event.content?.join_authorised_via_users_server;
|
|
108
|
+
if (membership === "join" && typeof via === "string" && roomVersionFeatures(roomVersion).restricted) keys.push(stateKey(M.ROOM.MEMBER, via));
|
|
109
|
+
}
|
|
110
|
+
return [...new Set(keys)];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Build the auth state for an event from its auth_events, validating rule 2 of the
|
|
114
|
+
* authorization rules (no duplicates, only expected types). For v12 the create event
|
|
115
|
+
* (identified by the room ID) must be supplied separately.
|
|
116
|
+
*/
|
|
117
|
+
function authStateFromAuthEvents(event, authEvents, roomVersion, createEvent) {
|
|
118
|
+
const allowed = new Set(authEventKeys(event, roomVersion));
|
|
119
|
+
const state = /* @__PURE__ */ new Map();
|
|
120
|
+
for (const ev of authEvents) {
|
|
121
|
+
if (ev.state_key === void 0) throw errors.forbidden("Auth event is not a state event");
|
|
122
|
+
const key = stateKey(ev.type, ev.state_key);
|
|
123
|
+
if (state.has(key)) throw errors.forbidden("Duplicate auth event");
|
|
124
|
+
if (!allowed.has(key)) throw errors.forbidden(`Unexpected auth event ${ev.type}`);
|
|
125
|
+
if (roomVersionFeatures(roomVersion).v12 && ev.type === M.ROOM.CREATE) throw errors.forbidden("Create event must not be in auth_events");
|
|
126
|
+
state.set(key, ev);
|
|
127
|
+
}
|
|
128
|
+
if (createEvent) state.set(stateKey(M.ROOM.CREATE, ""), createEvent);
|
|
129
|
+
return state;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The Matrix authorization rules: throws M_FORBIDDEN if `event` is not allowed
|
|
133
|
+
* given `state` (either its auth events or the state before it).
|
|
134
|
+
*/
|
|
135
|
+
function checkAuth(event, state, roomVersion, opts = {}) {
|
|
136
|
+
const features = roomVersionFeatures(roomVersion);
|
|
137
|
+
const n = versionNum(roomVersion);
|
|
138
|
+
const deny = (msg) => {
|
|
139
|
+
throw errors.forbidden(msg);
|
|
140
|
+
};
|
|
141
|
+
if (event.type === M.ROOM.CREATE) {
|
|
142
|
+
if (state.size > 0 || Array.isArray(event.prev_events) && event.prev_events.length > 0) deny("Create event must be the first event");
|
|
143
|
+
if (!features.v12 && event.room_id && !sameDomain(event.room_id, event.sender)) deny("Room ID domain must match the creator");
|
|
144
|
+
const rv = event.content?.room_version;
|
|
145
|
+
if (rv !== void 0 && (typeof rv !== "string" || !Number.isFinite(versionNum(rv)))) deny("Unknown room version");
|
|
146
|
+
if (!features.v11 && typeof event.content?.creator !== "string") deny("Create event lacks a creator");
|
|
147
|
+
if (features.v12) {
|
|
148
|
+
const extra = event.content?.additional_creators;
|
|
149
|
+
if (extra !== void 0 && (!Array.isArray(extra) || !extra.every(isValidUserId))) deny("Invalid additional_creators");
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const create = getState(state, M.ROOM.CREATE);
|
|
154
|
+
if (!create) deny("No create event in room");
|
|
155
|
+
if (create.content?.[M.FEDERATE] === false && !sameDomain(event.sender, create.sender)) deny("Room is not federated");
|
|
156
|
+
if (n <= 5 && event.type === M.ROOM.ALIASES) {
|
|
157
|
+
if (event.state_key === void 0) deny("Aliases event lacks a state key");
|
|
158
|
+
if (event.state_key !== domainOf(event.sender)) deny("Aliases state key must be the sender's domain");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const pl = powerLevels(state, roomVersion);
|
|
162
|
+
const senderMembership = membershipOf(state, event.sender);
|
|
163
|
+
const senderLevel = userLevel(pl, event.sender);
|
|
164
|
+
if (event.type === M.ROOM.MEMBER) {
|
|
165
|
+
const target = event.state_key;
|
|
166
|
+
const membership = event.content?.membership;
|
|
167
|
+
if (typeof target !== "string" || typeof membership !== "string") deny("Member event lacks state_key or membership");
|
|
168
|
+
const targetMembership = membershipOf(state, target);
|
|
169
|
+
const joinRule = getState(state, M.ROOM.JOIN_RULES)?.content?.join_rule ?? "invite";
|
|
170
|
+
const via = event.content?.join_authorised_via_users_server;
|
|
171
|
+
switch (membership) {
|
|
172
|
+
case "join": {
|
|
173
|
+
const prev = Array.isArray(event.prev_events) ? event.prev_events.map((p) => Array.isArray(p) ? p[0] : p) : [];
|
|
174
|
+
const creator = roomCreator(state, roomVersion);
|
|
175
|
+
if (prev.length === 1 && prev[0] === create.event_id && target === creator && event.sender === target) return;
|
|
176
|
+
if (state.size === 1 && creator === target && event.sender === target) return;
|
|
177
|
+
if (event.sender !== target) deny("Cannot force another user to join");
|
|
178
|
+
if (targetMembership === "ban") deny("User is banned from the room");
|
|
179
|
+
if (joinRule === "invite" || features.knock && joinRule === "knock") {
|
|
180
|
+
if (targetMembership === "join" || targetMembership === "invite") return;
|
|
181
|
+
deny("You are not invited to this room");
|
|
182
|
+
}
|
|
183
|
+
if (features.restricted && joinRule === "restricted" || features.knockRestricted && joinRule === "knock_restricted") {
|
|
184
|
+
if (targetMembership === "join" || targetMembership === "invite") return;
|
|
185
|
+
if (typeof via === "string" && opts.joinAuthorisedSignatureValid === false) deny("join_authorised_via_users_server is not signed by that server");
|
|
186
|
+
if (typeof via === "string" && membershipOf(state, via) === "join" && userLevel(pl, via) >= pl.invite) return;
|
|
187
|
+
deny("Join not authorised by restricted join rules");
|
|
188
|
+
}
|
|
189
|
+
if (joinRule === "public") return;
|
|
190
|
+
deny("You are not allowed to join this room");
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case "invite":
|
|
194
|
+
if (event.content?.third_party_invite !== void 0) {
|
|
195
|
+
if (targetMembership === "ban") deny("User is banned");
|
|
196
|
+
if (!checkThirdPartyInvite(event, state)) deny("Invalid third party invite");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (senderMembership !== "join") deny("Sender not in room");
|
|
200
|
+
if (targetMembership === "join" || targetMembership === "ban") deny(`User is already ${targetMembership === "join" ? "joined" : "banned"}`);
|
|
201
|
+
if (senderLevel >= pl.invite) return;
|
|
202
|
+
deny("Insufficient power level to invite");
|
|
203
|
+
break;
|
|
204
|
+
case "leave":
|
|
205
|
+
if (event.sender === target) {
|
|
206
|
+
if (targetMembership === "join" || targetMembership === "invite" || features.knock && targetMembership === "knock") return;
|
|
207
|
+
deny("User is not in the room");
|
|
208
|
+
}
|
|
209
|
+
if (senderMembership !== "join") deny("Sender not in room");
|
|
210
|
+
if (targetMembership === "ban" && senderLevel < pl.ban) deny("Insufficient power level to unban");
|
|
211
|
+
if (senderLevel >= pl.kick && userLevel(pl, target) < senderLevel) return;
|
|
212
|
+
deny("Insufficient power level to kick");
|
|
213
|
+
break;
|
|
214
|
+
case "ban":
|
|
215
|
+
if (senderMembership !== "join") deny("Sender not in room");
|
|
216
|
+
if (senderLevel >= pl.ban && userLevel(pl, target) < senderLevel) return;
|
|
217
|
+
deny("Insufficient power level to ban");
|
|
218
|
+
break;
|
|
219
|
+
case "knock":
|
|
220
|
+
if (!features.knock) deny("Knocking is not supported in this room version");
|
|
221
|
+
if (joinRule !== "knock" && !(features.knockRestricted && joinRule === "knock_restricted")) deny("Room does not allow knocking");
|
|
222
|
+
if (event.sender !== target) deny("Cannot knock on behalf of another user");
|
|
223
|
+
if (targetMembership === "ban" || targetMembership === "join" || targetMembership === "invite") deny(`Cannot knock while ${targetMembership}`);
|
|
224
|
+
return;
|
|
225
|
+
default: deny("Unknown membership");
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (senderMembership !== "join") deny("You are not joined to this room");
|
|
230
|
+
if (event.type === M.ROOM.THIRD_PARTY_INVITE) {
|
|
231
|
+
if (senderLevel >= pl.invite) return;
|
|
232
|
+
deny("Insufficient power level to invite");
|
|
233
|
+
}
|
|
234
|
+
const isState = event.state_key !== void 0;
|
|
235
|
+
if (senderLevel < requiredLevel(pl, event.type, isState)) deny("Insufficient power level to send this event");
|
|
236
|
+
if (isState && event.state_key.startsWith("@") && event.state_key !== event.sender) {
|
|
237
|
+
if (!features.ownedState) deny("State keys starting with @ are reserved for that user");
|
|
238
|
+
const owner = ownerOfStateKey(event.state_key);
|
|
239
|
+
if (!owner) deny("Malformed user-owned state key");
|
|
240
|
+
if (owner !== event.sender && senderLevel <= userLevel(pl, owner)) deny("This state key belongs to another user");
|
|
241
|
+
}
|
|
242
|
+
if (event.type === M.ROOM.POWER_LEVELS && isState) {
|
|
243
|
+
if (isObject(event.content?.users)) for (const u of Object.keys(event.content.users)) {
|
|
244
|
+
if (!isValidUserId(u)) deny("Power level users must be valid user IDs");
|
|
245
|
+
if (pl.creators.has(u)) deny("Room creators cannot be given a power level");
|
|
246
|
+
}
|
|
247
|
+
if (getState(state, M.ROOM.POWER_LEVELS)) checkPowerLevelChange(event, pl, senderLevel, features.integerPowerLevels, n >= 6, deny);
|
|
248
|
+
else checkPowerLevelShape(event, features.integerPowerLevels, deny);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (n <= 2 && event.type === M.ROOM.REDACTION) {
|
|
252
|
+
if (senderLevel >= pl.redact) return;
|
|
253
|
+
const redacts = event.redacts;
|
|
254
|
+
if (typeof redacts === "string" && redacts.includes(":") && domainOf(redacts) === domainOf(event.sender)) return;
|
|
255
|
+
deny("Insufficient power level to redact");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function checkThirdPartyInvite(event, state) {
|
|
259
|
+
const signed = event.content?.third_party_invite?.signed;
|
|
260
|
+
if (!isObject(signed) || typeof signed.mxid !== "string" || typeof signed.token !== "string") return false;
|
|
261
|
+
if (signed.mxid !== event.state_key) return false;
|
|
262
|
+
const invite = getState(state, M.ROOM.THIRD_PARTY_INVITE, signed.token);
|
|
263
|
+
if (!invite || invite.sender !== event.sender) return false;
|
|
264
|
+
const keys = [];
|
|
265
|
+
if (typeof invite.content?.public_key === "string") keys.push(invite.content.public_key);
|
|
266
|
+
for (const k of Array.isArray(invite.content?.public_keys) ? invite.content.public_keys : []) if (typeof k?.public_key === "string") keys.push(k.public_key);
|
|
267
|
+
for (const key of keys) {
|
|
268
|
+
let pub;
|
|
269
|
+
try {
|
|
270
|
+
pub = publicKeyFromBase64(key);
|
|
271
|
+
} catch {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
for (const [server, sigs] of Object.entries(isObject(signed.signatures) ? signed.signatures : {})) for (const keyId of Object.keys(isObject(sigs) ? sigs : {})) if (verifyJsonSignature(signed, server, keyId, pub)) return true;
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
function checkPowerLevelShape(event, integerOnly, deny) {
|
|
279
|
+
if (!integerOnly) return;
|
|
280
|
+
const c = event.content ?? {};
|
|
281
|
+
const isInt = (v) => typeof v === "number" && Number.isInteger(v);
|
|
282
|
+
for (const key of [
|
|
283
|
+
"users_default",
|
|
284
|
+
"events_default",
|
|
285
|
+
"state_default",
|
|
286
|
+
"ban",
|
|
287
|
+
"redact",
|
|
288
|
+
"kick",
|
|
289
|
+
"invite"
|
|
290
|
+
]) if (key in c && !isInt(c[key])) deny(`${key} must be an integer`);
|
|
291
|
+
for (const map of [
|
|
292
|
+
"users",
|
|
293
|
+
"events",
|
|
294
|
+
"notifications"
|
|
295
|
+
]) {
|
|
296
|
+
if (map in c && !isObject(c[map])) deny(`${map} must be an object`);
|
|
297
|
+
if (isObject(c[map])) {
|
|
298
|
+
for (const v of Object.values(c[map])) if (!isInt(v)) deny(`${map} values must be integers`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function checkPowerLevelChange(event, current, senderLevel, integerOnly, checkNotifications, deny) {
|
|
303
|
+
const c = event.content ?? {};
|
|
304
|
+
checkPowerLevelShape(event, integerOnly, deny);
|
|
305
|
+
if (!integerOnly) {
|
|
306
|
+
for (const map of [
|
|
307
|
+
"users",
|
|
308
|
+
"events",
|
|
309
|
+
"notifications"
|
|
310
|
+
]) if (map in c && !isObject(c[map])) deny(`${map} must be an object`);
|
|
311
|
+
}
|
|
312
|
+
const next = {
|
|
313
|
+
users_default: toInt(c.users_default, 0),
|
|
314
|
+
events_default: toInt(c.events_default, 0),
|
|
315
|
+
state_default: toInt(c.state_default, 50),
|
|
316
|
+
ban: toInt(c.ban, 50),
|
|
317
|
+
redact: toInt(c.redact, 50),
|
|
318
|
+
kick: toInt(c.kick, 50),
|
|
319
|
+
invite: toInt(c.invite, 0)
|
|
320
|
+
};
|
|
321
|
+
for (const key of Object.keys(next)) {
|
|
322
|
+
const old = current[key];
|
|
323
|
+
if (old !== next[key] && (old > senderLevel || next[key] > senderLevel)) deny(`Cannot change ${key} beyond your own level`);
|
|
324
|
+
}
|
|
325
|
+
const compareMap = (oldMap, newMap, isUsers) => {
|
|
326
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(oldMap), ...Object.keys(newMap ?? {})]);
|
|
327
|
+
for (const k of keys) {
|
|
328
|
+
const o = oldMap[k];
|
|
329
|
+
const nv = newMap?.[k] === void 0 ? void 0 : toInt(newMap[k], 0);
|
|
330
|
+
if (o === nv) continue;
|
|
331
|
+
if (isUsers) {
|
|
332
|
+
if (k !== event.sender && o !== void 0 && o >= senderLevel) deny("Cannot change the level of a user with equal or higher power");
|
|
333
|
+
if (nv !== void 0 && nv > senderLevel) deny("Cannot set a level above your own");
|
|
334
|
+
} else {
|
|
335
|
+
if (o !== void 0 && o > senderLevel) deny("Cannot change a level above your own");
|
|
336
|
+
if (nv !== void 0 && nv > senderLevel) deny("Cannot set a level above your own");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
compareMap(current.users, c.users ?? {}, true);
|
|
341
|
+
compareMap(current.events, c.events ?? {}, false);
|
|
342
|
+
if (checkNotifications) compareMap({ ...current.notifications }, {
|
|
343
|
+
room: 50,
|
|
344
|
+
...c.notifications
|
|
345
|
+
}, false);
|
|
346
|
+
}
|
|
347
|
+
//#endregion
|
|
348
|
+
export { authEventKeys, authStateFromAuthEvents, checkAuth, getState, membershipOf, ownerOfStateKey, powerLevels, requiredLevel, roomCreator, roomCreators, userLevel };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
//#region src/core/cluster.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Coordination between homeserver instances sharing one database. The default
|
|
4
|
+
* (`localCluster()`) keeps everything in process; use `postgresCluster()` or
|
|
5
|
+
* `redisCluster()` to run several instances behind a load balancer.
|
|
6
|
+
*/
|
|
7
|
+
export interface ClusterAdapter {
|
|
8
|
+
/** Allocate the next value of a cluster-wide, strictly increasing sequence. */
|
|
9
|
+
nextId(sequence: string): Promise<number>;
|
|
10
|
+
/** Current (latest allocated) value of a sequence. */
|
|
11
|
+
currentId(sequence: string): Promise<number>;
|
|
12
|
+
/** Ensure a sequence is at least `value` (used at startup with existing data). */
|
|
13
|
+
advance(sequence: string, value: number): Promise<void>;
|
|
14
|
+
/** Run `fn` while holding a cluster-wide mutex for `key`. */
|
|
15
|
+
lock<T>(key: string, fn: () => Promise<T>): Promise<T>;
|
|
16
|
+
publish(channel: string, message: unknown): Promise<void>;
|
|
17
|
+
subscribe(channel: string, handler: (message: any) => void): void;
|
|
18
|
+
/** Ephemeral shared key-value storage. */
|
|
19
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
20
|
+
set(key: string, value: unknown, ttlMs?: number): Promise<void>;
|
|
21
|
+
/** Set only if the key is absent (or expired). Returns true when the value was set. */
|
|
22
|
+
setIfAbsent(key: string, value: unknown, ttlMs?: number): Promise<boolean>;
|
|
23
|
+
delete(key: string): Promise<void>;
|
|
24
|
+
/** All live entries whose key starts with `prefix`. */
|
|
25
|
+
entries<T = unknown>(prefix: string): Promise<[string, T][]>;
|
|
26
|
+
close?(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/** In-process cluster adapter: a single homeserver instance. */
|
|
29
|
+
export declare function localCluster(): ClusterAdapter;
|
|
30
|
+
export interface StreamLease {
|
|
31
|
+
id: number;
|
|
32
|
+
/** Mark the position as persisted (or abandoned). */
|
|
33
|
+
done(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Stream positions with multiple writers. Positions are allocated from a global
|
|
37
|
+
* sequence; readers only see positions up to which every writer has finished, so
|
|
38
|
+
* an event committed late with a lower position can never be skipped by /sync.
|
|
39
|
+
*
|
|
40
|
+
* Each writer publishes `{ busy, pos }`: when busy, all of its positions ≤ pos are
|
|
41
|
+
* persisted. Readers use min(global, busy writers' pos).
|
|
42
|
+
*/
|
|
43
|
+
export declare class StreamManager {
|
|
44
|
+
private cluster;
|
|
45
|
+
private instanceId;
|
|
46
|
+
private inflight;
|
|
47
|
+
private published;
|
|
48
|
+
private transition;
|
|
49
|
+
private heartbeat;
|
|
50
|
+
private positionCache;
|
|
51
|
+
constructor(cluster: ClusterAdapter, instanceId: string);
|
|
52
|
+
private key;
|
|
53
|
+
private publishState;
|
|
54
|
+
/** Allocate a position. The caller must call `done()` once its writes are committed. */
|
|
55
|
+
allocate(): Promise<StreamLease>;
|
|
56
|
+
/** Allocate a position, run `fn` with it, then complete it. */
|
|
57
|
+
write<T>(fn: (id: number) => Promise<T>): Promise<T>;
|
|
58
|
+
/** A fresh decreasing position for backfilled (historical) events. */
|
|
59
|
+
backfillId(): Promise<number>;
|
|
60
|
+
/** The highest position below which everything is persisted, cluster-wide. */
|
|
61
|
+
position(): Promise<number>;
|
|
62
|
+
advanceTo(value: number): Promise<void>;
|
|
63
|
+
lowerBackfillTo(value: number): Promise<void>;
|
|
64
|
+
stop(): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|