matrix-nodejs-homeserver 0.3.5 → 0.3.6

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/AUDITBOT.md ADDED
@@ -0,0 +1,67 @@
1
+ # Audit bot
2
+
3
+ `matrix-nodejs-homeserver/auditbot` records the rooms of the server, end-to-end encrypted ones
4
+ included, to an archive: an account of the server that is a member of every room (or every room
5
+ but direct messages) and writes each event as one JSON line.
6
+
7
+ It needs the crypto library, an optional peer dependency:
8
+
9
+ ```sh
10
+ npm install matrix-nodejs-homeserver @matrix-org/matrix-sdk-crypto-nodejs
11
+ ```
12
+
13
+ ```ts
14
+ import { createHomeserver } from "matrix-nodejs-homeserver";
15
+ import { fileSink, startAuditBot } from "matrix-nodejs-homeserver/auditbot";
16
+
17
+ const hs = createHomeserver({ /* … */ });
18
+ const bot = await startAuditBot(hs, {
19
+ sink: fileSink("/data/audit"), // {room ID}/{YYYY-MM-DD}.jsonl
20
+ dataDir: "/data/auditbot", // crypto store and sync position: keep and back up
21
+ storePassphrase: process.env.AUDIT_STORE_PASSPHRASE!,
22
+ rooms: "groups", // or "all" (default): with direct messages
23
+ });
24
+ // await bot.stop() on shutdown, before hs.close()
25
+ ```
26
+
27
+ ## How it works
28
+
29
+ - **It is a room member.** It joins new rooms on its own, and existing ones at start and every
30
+ 10 minutes. Public rooms are joined directly. In other rooms, a local member who may invite
31
+ invites it (`hs.admin.joinUser`, Synapse's `POST /_synapse/admin/v1/join`). The invite shows in
32
+ the room, and the bot is in the member list as "Audit log (this room is recorded)", so everyone
33
+ can see that a room is recorded.
34
+ - **End-to-end encryption.** It is a device like any other, with matrix-sdk-crypto (the Rust
35
+ crypto of the Element apps). Clients send it the room keys as they do to every member's device,
36
+ and it decrypts the events. Its device is signed by its own cross-signing identity. Keys live in
37
+ an encrypted SQLite store in `dataDir`, so it reads messages again after a restart.
38
+ - **Direct messages** are left out with `rooms: "groups"`. It does not join rooms created with
39
+ `is_direct`, and it leaves a room that turns out to be one.
40
+ - **In-process.** It runs in the homeserver's process and calls its API through the request
41
+ handler, with no network and no port.
42
+
43
+ What it cannot read: anything sent before it joined a room. It also misses messages from
44
+ clients set to share keys only with verified devices. A message whose key never arrives is
45
+ recorded after an hour with `decryption_error` and its ciphertext.
46
+
47
+ ## Records
48
+
49
+ ```json
50
+ {"recorded_at":1792270000000,"room_id":"!abc:example.com","event_id":"$…","type":"m.room.message",
51
+ "sender":"@alice:example.com","origin_server_ts":1792269999000,
52
+ "content":{"msgtype":"m.text","body":"hello"},"encrypted":true,"sender_device":"ABCDEFGHIJ"}
53
+ ```
54
+
55
+ `state_key` and `redacts` appear when the event has them. `initial_state: true` marks state the
56
+ room had when the bot joined. A sink is anything with `write(records)`: `fileSink(dir)`,
57
+ `memorySink()`, or your own (S3, a SIEM, a database).
58
+
59
+ ## Before you turn it on
60
+
61
+ Recording people's conversations, encrypted ones included, needs a legal basis and telling them:
62
+ terms of use, a privacy policy, and in the EU the GDPR. The bot's name says it in every room, but
63
+ it does not replace those.
64
+
65
+ The crypto library is a native module (Linux, macOS, Windows; x64 and arm64). Its install script
66
+ downloads it from the project's GitHub releases. With `--ignore-scripts`, run
67
+ `node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js`.
package/README.md CHANGED
@@ -345,6 +345,18 @@ up for admins (`/_synapse/admin/v1/event_reports`, `reports list`), and the admi
345
345
  room is notified. `GET /_matrix/client/v3/admin/whois/{userId}` shows a user's
346
346
  devices and IP addresses.
347
347
 
348
+ ## Audit bot
349
+
350
+ `matrix-nodejs-homeserver/auditbot` records every room of the server to an archive, end-to-end
351
+ encrypted ones decrypted, like the Auditing of Element Server Suite Pro. The bot is a room
352
+ member with its own device, visible in member lists. See [AUDITBOT.md](https://github.com/k18k/matrix-nodejs-homeserver/blob/main/packages/core/AUDITBOT.md).
353
+
354
+ ```ts
355
+ import { fileSink, startAuditBot } from "matrix-nodejs-homeserver/auditbot";
356
+
357
+ await startAuditBot(hs, { sink: fileSink("/data/audit"), dataDir: "/data/auditbot", storePassphrase, rooms: "groups" });
358
+ ```
359
+
348
360
  ## Administration
349
361
 
350
362
  You can use `hs.admin` from your own code:
package/dist/api/admin.js CHANGED
@@ -133,6 +133,11 @@ function registerAdminRoutes(r) {
133
133
  if (body.deactivated === true) await ctx.hs.admin.deactivateUser(userId);
134
134
  return ctx.hs.admin.getUser(userId);
135
135
  } }));
136
+ r.post("/_synapse/admin/v1/join/:roomIdOrAlias", procedure({
137
+ guard: adminGuard,
138
+ input: body({ user_id: string(fail("M_MISSING_PARAM", "user_id is required", "user_id")) }),
139
+ handler: async (ctx) => ({ room_id: await ctx.hs.admin.joinUser(ctx.input.user_id, ctx.params.roomIdOrAlias) })
140
+ }));
136
141
  r.post("/_synapse/admin/v1/users/:userId/_allow_cross_signing_replacement_without_uia", procedure({
137
142
  guard: adminGuard,
138
143
  handler: async (ctx) => {
@@ -0,0 +1,37 @@
1
+ import { Homeserver } from "../homeserver.js";
2
+ import { AuditSink } from "./sinks.js";
3
+ //#region src/auditbot/bot.d.ts
4
+ export interface AuditBotOptions {
5
+ /** Where the records go: `fileSink(dir)`, or your own. */
6
+ sink: AuditSink;
7
+ /**
8
+ * Directory for the bot's state: its crypto store (room keys, device keys) and the sync
9
+ * position. Losing it loses the keys of encrypted rooms, so keep it on a persistent volume and
10
+ * back it up.
11
+ */
12
+ dataDir: string;
13
+ /** Encrypts the crypto store. Required; keep it with the backups of `dataDir`. */
14
+ storePassphrase: string;
15
+ /** Default "auditbot". */
16
+ localpart?: string;
17
+ /** The bot's name in member lists. Default "Audit log (this room is recorded)". */
18
+ displayName?: string;
19
+ /** "all" rooms, or "groups": not direct messages (rooms created with is_direct). Default "all". */
20
+ rooms?: "all" | "groups";
21
+ /** How often to look for rooms it is not in yet, in ms. Default 10 minutes. */
22
+ sweepIntervalMs?: number;
23
+ /** How long a new room has to be quiet before the bot joins it, in ms. Default 500. */
24
+ joinDelayMs?: number;
25
+ /** How long an undecryptable event waits for its room key before it is recorded as such. Default 1 hour. */
26
+ keyWaitMs?: number;
27
+ }
28
+ export interface AuditBot {
29
+ userId: string;
30
+ /** Resolves once the bot has synced up to now (tests). */
31
+ synced(): Promise<void>;
32
+ /** Join every room it is not in yet (it also does this on its own). */
33
+ sweep(): Promise<void>;
34
+ stop(): Promise<void>;
35
+ }
36
+ export declare function startAuditBot(hs: Homeserver, options: AuditBotOptions): Promise<AuditBot>;
37
+ //#endregion
@@ -0,0 +1,208 @@
1
+ import { getFetchHandler } from "../http/adapters.js";
2
+ import { CryptoClient } from "./client.js";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ //#region src/auditbot/bot.ts
6
+ const MAX_PENDING = 1e4;
7
+ async function startAuditBot(hs, options) {
8
+ await hs.ready;
9
+ const localpart = options.localpart ?? "auditbot";
10
+ const userId = `@${localpart}:${hs.serverName}`;
11
+ const displayName = options.displayName ?? "Audit log (this room is recorded)";
12
+ await mkdir(options.dataDir, { recursive: true });
13
+ const statePath = join(options.dataDir, "state.json");
14
+ const saved = await readFile(statePath, "utf8").then(JSON.parse, () => null);
15
+ if (!await hs.admin.getUser(userId)) await hs.admin.createUser(localpart, { displayname: displayName });
16
+ const session = await hs.auth.createSession(userId, {
17
+ deviceId: saved?.deviceId,
18
+ displayName: "Audit bot"
19
+ });
20
+ const state = {
21
+ deviceId: session.device_id,
22
+ since: saved?.since
23
+ };
24
+ const save = () => writeFile(statePath, JSON.stringify(state));
25
+ await save();
26
+ const client = new CryptoClient({
27
+ fetch: getFetchHandler(hs),
28
+ userId,
29
+ deviceId: session.device_id,
30
+ accessToken: session.access_token
31
+ });
32
+ await client.open(join(options.dataDir, "crypto"), options.storePassphrase);
33
+ await client.flush();
34
+ await client.ensureCrossSigning();
35
+ await client.request("PUT", `/_matrix/client/v3/profile/${encodeURIComponent(userId)}/displayname`, { displayname: displayName });
36
+ const pending = [];
37
+ const recorded = /* @__PURE__ */ new Set();
38
+ const encryptedRooms = /* @__PURE__ */ new Set();
39
+ let stopped = false;
40
+ const abort = new AbortController();
41
+ let syncedUpTo = 0;
42
+ const syncWaiters = [];
43
+ const isDirect = async (roomId) => {
44
+ const state = await hs.rooms.currentState(roomId);
45
+ for (const ev of state.values()) if (ev.type === "m.room.member" && ev.content?.is_direct === true) return true;
46
+ return false;
47
+ };
48
+ const joining = /* @__PURE__ */ new Map();
49
+ const ensureJoined = (roomId) => {
50
+ const running = joining.get(roomId);
51
+ if (running) return running;
52
+ const p = (async () => {
53
+ if ((await hs.rooms.roomsForUser(userId)).includes(roomId)) return;
54
+ if (options.rooms === "groups" && await isDirect(roomId)) return;
55
+ try {
56
+ await hs.admin.joinUser(userId, roomId);
57
+ hs.logger.info("Audit bot joined", roomId);
58
+ } catch (err) {
59
+ hs.logger.warn("Audit bot could not join", roomId, err.message);
60
+ }
61
+ })().finally(() => joining.delete(roomId));
62
+ joining.set(roomId, p);
63
+ return p;
64
+ };
65
+ const sweep = async () => {
66
+ const { rooms } = await hs.admin.listRooms({ limit: 1e6 });
67
+ for (const r of rooms) await ensureJoined(r.room_id);
68
+ };
69
+ const quietTimers = /* @__PURE__ */ new Map();
70
+ const joinWhenQuiet = (roomId) => {
71
+ clearTimeout(quietTimers.get(roomId));
72
+ quietTimers.set(roomId, setTimeout(() => {
73
+ quietTimers.delete(roomId);
74
+ ensureJoined(roomId);
75
+ }, options.joinDelayMs ?? 500));
76
+ };
77
+ const onEvent = (ev) => {
78
+ if (stopped || ev.type !== "m.room.member" || ev.state_key === userId) return;
79
+ if (ev.content?.membership === "join") joinWhenQuiet(ev.room_id);
80
+ if (options.rooms === "groups" && ev.content?.is_direct === true) (async () => {
81
+ if (!(await hs.rooms.joinedMembers(ev.room_id)).includes(userId)) return;
82
+ await client.request("POST", `/_matrix/client/v3/rooms/${encodeURIComponent(ev.room_id)}/leave`, {}).catch(() => {});
83
+ hs.logger.info("Audit bot left a direct message room", ev.room_id);
84
+ })();
85
+ };
86
+ hs.on("event", onEvent);
87
+ const toRecord = (roomId, ev, extra = {}) => ({
88
+ recorded_at: Date.now(),
89
+ room_id: roomId,
90
+ event_id: ev.event_id,
91
+ type: ev.type,
92
+ sender: ev.sender,
93
+ origin_server_ts: ev.origin_server_ts,
94
+ ...ev.state_key !== void 0 ? { state_key: ev.state_key } : {},
95
+ content: ev.content ?? {},
96
+ ...ev.redacts ? { redacts: ev.redacts } : {},
97
+ encrypted: false,
98
+ ...extra
99
+ });
100
+ /** A record for an event, decrypting it when it is encrypted; null when its key is not here yet. */
101
+ const process = async (roomId, ev) => {
102
+ if (ev.type !== "m.room.encrypted") return toRecord(roomId, ev);
103
+ try {
104
+ const decrypted = await client.decrypt(ev, roomId);
105
+ const clear = JSON.parse(decrypted.event);
106
+ return toRecord(roomId, {
107
+ ...ev,
108
+ type: clear.type,
109
+ content: clear.content
110
+ }, {
111
+ encrypted: true,
112
+ sender_device: decrypted.senderDevice?.toString()
113
+ });
114
+ } catch {
115
+ return null;
116
+ }
117
+ };
118
+ const write = async (records) => {
119
+ const fresh = records.filter((r) => !recorded.has(r.event_id));
120
+ if (fresh.length === 0) return;
121
+ await options.sink.write(fresh);
122
+ for (const r of fresh) recorded.add(r.event_id);
123
+ if (recorded.size > 1e5) recorded.clear();
124
+ };
125
+ const retryPending = async () => {
126
+ const out = [];
127
+ const keyWait = options.keyWaitMs ?? 36e5;
128
+ for (let i = pending.length - 1; i >= 0; i--) {
129
+ const p = pending[i];
130
+ const record = await process(p.roomId, p.event);
131
+ if (record || Date.now() - p.since > keyWait) {
132
+ out.push(record ?? toRecord(p.roomId, p.event, {
133
+ encrypted: true,
134
+ decryption_error: "The room key did not reach the audit bot"
135
+ }));
136
+ pending.splice(i, 1);
137
+ }
138
+ }
139
+ await write(out.reverse());
140
+ };
141
+ const handleSync = async (sync) => {
142
+ await client.receiveSync(sync);
143
+ const out = [];
144
+ for (const [roomId, room] of Object.entries(sync.rooms?.join ?? {})) {
145
+ const timeline = room.timeline?.events ?? [];
146
+ const stateEvents = room.state?.events ?? [];
147
+ for (const ev of [...stateEvents, ...timeline]) if (ev.type === "m.room.encryption") encryptedRooms.add(roomId);
148
+ if (encryptedRooms.has(roomId) && (stateEvents.length || timeline.some((e) => e.type === "m.room.member"))) await client.track(await hs.rooms.joinedMembers(roomId));
149
+ for (const ev of stateEvents) out.push(toRecord(roomId, ev, { initial_state: true }));
150
+ for (const ev of timeline) {
151
+ const record = await process(roomId, ev);
152
+ if (record) out.push(record);
153
+ else if (pending.length < MAX_PENDING) pending.push({
154
+ roomId,
155
+ event: ev,
156
+ since: Date.now()
157
+ });
158
+ }
159
+ }
160
+ await write(out);
161
+ await retryPending();
162
+ };
163
+ const loop = async () => {
164
+ while (!stopped) try {
165
+ const params = new URLSearchParams({
166
+ timeout: "30000",
167
+ filter: JSON.stringify({ room: { timeline: { limit: 200 } } })
168
+ });
169
+ if (state.since) params.set("since", state.since);
170
+ const sync = await client.request("GET", `/_matrix/client/v3/sync?${params}`, void 0, abort.signal);
171
+ await handleSync(sync);
172
+ state.since = sync.next_batch;
173
+ await save();
174
+ syncedUpTo = Date.now();
175
+ for (const w of syncWaiters.splice(0)) w();
176
+ } catch (err) {
177
+ if (stopped) return;
178
+ hs.logger.warn("Audit bot sync failed", err.message);
179
+ await new Promise((r) => setTimeout(r, 5e3));
180
+ }
181
+ };
182
+ await sweep();
183
+ const running = loop();
184
+ const timer = setInterval(() => void sweep().catch(() => {}), options.sweepIntervalMs ?? 6e5);
185
+ timer.unref();
186
+ return {
187
+ userId,
188
+ sweep,
189
+ synced() {
190
+ const asked = Date.now();
191
+ return new Promise((resolve) => {
192
+ const check = () => syncedUpTo > asked ? resolve() : syncWaiters.push(check);
193
+ check();
194
+ });
195
+ },
196
+ async stop() {
197
+ stopped = true;
198
+ abort.abort();
199
+ clearInterval(timer);
200
+ for (const t of quietTimers.values()) clearTimeout(t);
201
+ hs.off("event", onEvent);
202
+ await running.catch(() => {});
203
+ client.close();
204
+ }
205
+ };
206
+ }
207
+ //#endregion
208
+ export { startAuditBot };
@@ -0,0 +1,41 @@
1
+ import { DecryptedRoomEvent, OlmMachine } from "@matrix-org/matrix-sdk-crypto-nodejs";
2
+ //#region src/auditbot/client.d.ts
3
+ export type Fetch = (request: Request) => Promise<Response>;
4
+ export declare class CryptoClient {
5
+ readonly userId: string;
6
+ readonly deviceId: string;
7
+ private accessToken;
8
+ private fetch;
9
+ private baseUrl;
10
+ private txn;
11
+ machine: OlmMachine;
12
+ constructor(opts: {
13
+ fetch: Fetch;
14
+ baseUrl?: string;
15
+ userId: string;
16
+ deviceId: string;
17
+ accessToken: string;
18
+ });
19
+ /**
20
+ * Open the crypto store. With `storePath`, keys live in an encrypted SQLite store there (they
21
+ * must survive restarts: room keys are sent once). Without, in memory (tests).
22
+ */
23
+ open(storePath?: string, passphrase?: string): Promise<void>;
24
+ request<T = any>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T>;
25
+ /** Feed a /sync response to the crypto machine, then send what it needs sent. */
26
+ receiveSync(sync: Record<string, any>): Promise<void>;
27
+ /** Send every outgoing request of the crypto machine (key uploads, queries, claims, to-device). */
28
+ flush(): Promise<void>;
29
+ /** Make sure the crypto machine knows these users' devices. */
30
+ track(users: string[]): Promise<void>;
31
+ decrypt(event: Record<string, any>, roomId: string): Promise<DecryptedRoomEvent>;
32
+ /**
33
+ * Set up cross-signing for this account and sign the device with it, so other users' clients
34
+ * see the device as its owner's (not as an unknown one). Only once: an existing identity is kept.
35
+ */
36
+ ensureCrossSigning(): Promise<void>;
37
+ /** Encrypt and send a message (the test's sender; the bot itself sends nothing encrypted). */
38
+ sendEncrypted(roomId: string, members: string[], content: Record<string, unknown>): Promise<string>;
39
+ close(): void;
40
+ }
41
+ //#endregion
@@ -0,0 +1,156 @@
1
+ import { DeviceId, DeviceLists, OlmMachine, RoomId, UserId } from "@matrix-org/matrix-sdk-crypto-nodejs";
2
+ //#region src/auditbot/client.ts
3
+ /** The request types of matrix-sdk-crypto (a const enum in its typings). */
4
+ const Req = {
5
+ KeysUpload: 0,
6
+ KeysQuery: 1,
7
+ KeysClaim: 2,
8
+ ToDevice: 3,
9
+ SignatureUpload: 4,
10
+ RoomMessage: 5,
11
+ KeysBackup: 6
12
+ };
13
+ /**
14
+ * matrix-sdk-crypto puts signature uploads under `signed_keys`; the endpoint takes the map of
15
+ * signatures itself as the body.
16
+ */
17
+ function signatureUploadBody(body) {
18
+ const parsed = JSON.parse(body);
19
+ return Object.keys(parsed).length === 1 && parsed.signed_keys ? JSON.stringify(parsed.signed_keys) : body;
20
+ }
21
+ var MatrixError = class extends Error {
22
+ status;
23
+ body;
24
+ constructor(status, body) {
25
+ super(`${status} ${body.errcode ?? ""} ${body.error ?? ""}`.trim());
26
+ this.status = status;
27
+ this.body = body;
28
+ }
29
+ };
30
+ var CryptoClient = class {
31
+ userId;
32
+ deviceId;
33
+ accessToken;
34
+ fetch;
35
+ baseUrl;
36
+ txn = 0;
37
+ machine;
38
+ constructor(opts) {
39
+ this.fetch = opts.fetch;
40
+ this.baseUrl = opts.baseUrl ?? "http://localhost";
41
+ this.userId = opts.userId;
42
+ this.deviceId = opts.deviceId;
43
+ this.accessToken = opts.accessToken;
44
+ }
45
+ /**
46
+ * Open the crypto store. With `storePath`, keys live in an encrypted SQLite store there (they
47
+ * must survive restarts: room keys are sent once). Without, in memory (tests).
48
+ */
49
+ async open(storePath, passphrase) {
50
+ this.machine = await OlmMachine.initialize(new UserId(this.userId), new DeviceId(this.deviceId), storePath ?? null, passphrase ?? null, storePath ? 0 : null);
51
+ }
52
+ async request(method, path, body, signal) {
53
+ const res = await this.fetch(new Request(`${this.baseUrl}${path}`, {
54
+ method,
55
+ signal,
56
+ headers: {
57
+ authorization: `Bearer ${this.accessToken}`,
58
+ "content-type": "application/json"
59
+ },
60
+ body: body === void 0 ? void 0 : typeof body === "string" ? body : JSON.stringify(body)
61
+ }));
62
+ const text = await res.text();
63
+ const json = text ? JSON.parse(text) : {};
64
+ if (!res.ok) throw new MatrixError(res.status, json);
65
+ return json;
66
+ }
67
+ /** Feed a /sync response to the crypto machine, then send what it needs sent. */
68
+ async receiveSync(sync) {
69
+ const changed = (sync.device_lists?.changed ?? []).map((u) => new UserId(u));
70
+ const left = (sync.device_lists?.left ?? []).map((u) => new UserId(u));
71
+ await this.machine.receiveSyncChanges(JSON.stringify(sync.to_device?.events ?? []), new DeviceLists(changed, left), sync.device_one_time_keys_count ?? {}, sync.device_unused_fallback_key_types ?? []);
72
+ await this.flush();
73
+ }
74
+ /** Send every outgoing request of the crypto machine (key uploads, queries, claims, to-device). */
75
+ async flush() {
76
+ for (let round = 0; round < 10; round++) {
77
+ const requests = await this.machine.outgoingRequests();
78
+ if (requests.length === 0) return;
79
+ for (const r of requests) {
80
+ let response;
81
+ switch (r.type) {
82
+ case Req.KeysUpload:
83
+ response = await this.request("POST", "/_matrix/client/v3/keys/upload", r.body);
84
+ break;
85
+ case Req.KeysQuery:
86
+ response = await this.request("POST", "/_matrix/client/v3/keys/query", r.body);
87
+ break;
88
+ case Req.KeysClaim:
89
+ response = await this.request("POST", "/_matrix/client/v3/keys/claim", r.body);
90
+ break;
91
+ case Req.SignatureUpload:
92
+ response = await this.request("POST", "/_matrix/client/v3/keys/signatures/upload", signatureUploadBody(r.body));
93
+ break;
94
+ case Req.ToDevice: {
95
+ const t = r;
96
+ response = await this.request("PUT", `/_matrix/client/v3/sendToDevice/${encodeURIComponent(t.eventType)}/${encodeURIComponent(t.txnId)}`, t.body);
97
+ break;
98
+ }
99
+ case Req.RoomMessage: {
100
+ const m = r;
101
+ response = await this.request("PUT", `/_matrix/client/v3/rooms/${encodeURIComponent(m.roomId)}/send/${encodeURIComponent(m.eventType)}/${encodeURIComponent(m.txnId)}`, m.body);
102
+ break;
103
+ }
104
+ default: continue;
105
+ }
106
+ await this.machine.markRequestAsSent(r.id, r.type, JSON.stringify(response));
107
+ }
108
+ }
109
+ }
110
+ /** Make sure the crypto machine knows these users' devices. */
111
+ async track(users) {
112
+ await this.machine.updateTrackedUsers(users.map((u) => new UserId(u)));
113
+ await this.flush();
114
+ }
115
+ decrypt(event, roomId) {
116
+ return this.machine.decryptRoomEvent(JSON.stringify(event), new RoomId(roomId));
117
+ }
118
+ /**
119
+ * Set up cross-signing for this account and sign the device with it, so other users' clients
120
+ * see the device as its owner's (not as an unknown one). Only once: an existing identity is kept.
121
+ */
122
+ async ensureCrossSigning() {
123
+ const status = await this.machine.crossSigningStatus();
124
+ if (status.hasMaster && status.hasSelfSigning) return;
125
+ const requests = await this.machine.bootstrapCrossSigning(true);
126
+ if (requests.uploadKeysReq) {
127
+ const response = await this.request("POST", "/_matrix/client/v3/keys/upload", requests.uploadKeysReq.body);
128
+ await this.machine.markRequestAsSent(requests.uploadKeysReq.id, requests.uploadKeysReq.type, JSON.stringify(response));
129
+ }
130
+ await this.request("POST", "/_matrix/client/v3/keys/device_signing/upload", requests.uploadSigningKeysReq);
131
+ const signatures = await this.request("POST", "/_matrix/client/v3/keys/signatures/upload", signatureUploadBody(requests.uploadSignaturesReq.body));
132
+ await this.machine.markRequestAsSent(requests.uploadSignaturesReq.id, requests.uploadSignaturesReq.type, JSON.stringify(signatures));
133
+ }
134
+ /** Encrypt and send a message (the test's sender; the bot itself sends nothing encrypted). */
135
+ async sendEncrypted(roomId, members, content) {
136
+ const { EncryptionSettings } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
137
+ await this.track(members);
138
+ const users = members.map((u) => new UserId(u));
139
+ const claim = await this.machine.getMissingSessions(users);
140
+ if (claim) {
141
+ const response = await this.request("POST", "/_matrix/client/v3/keys/claim", claim.body);
142
+ await this.machine.markRequestAsSent(claim.id, claim.type, JSON.stringify(response));
143
+ }
144
+ for (const r of await this.machine.shareRoomKey(new RoomId(roomId), users, new EncryptionSettings())) {
145
+ const response = await this.request("PUT", `/_matrix/client/v3/sendToDevice/${encodeURIComponent(r.eventType)}/${encodeURIComponent(r.txnId)}`, r.body);
146
+ await this.machine.markRequestAsSent(r.id, r.type, JSON.stringify(response));
147
+ }
148
+ const encrypted = await this.machine.encryptRoomEvent(new RoomId(roomId), "m.room.message", JSON.stringify(content));
149
+ return (await this.request("PUT", `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.encrypted/e2e${Date.now()}${this.txn++}`, encrypted)).event_id;
150
+ }
151
+ close() {
152
+ this.machine?.close();
153
+ }
154
+ };
155
+ //#endregion
156
+ export { CryptoClient, MatrixError };
@@ -0,0 +1,4 @@
1
+ import { AuditRecord, AuditSink, fileSink, memorySink } from "./sinks.js";
2
+ import { AuditBot, AuditBotOptions, startAuditBot } from "./bot.js";
3
+ import { CryptoClient } from "./client.js";
4
+ export { type AuditBot, type AuditBotOptions, type AuditRecord, type AuditSink, CryptoClient, fileSink, memorySink, startAuditBot };
@@ -0,0 +1,4 @@
1
+ import { CryptoClient } from "./client.js";
2
+ import { startAuditBot } from "./bot.js";
3
+ import { fileSink, memorySink } from "./sinks.js";
4
+ export { CryptoClient, fileSink, memorySink, startAuditBot };
@@ -0,0 +1,37 @@
1
+ //#region src/auditbot/sinks.d.ts
2
+ /** One recorded event, as written to the archive (one JSON object per line). */
3
+ export interface AuditRecord {
4
+ /** When the bot recorded it (ms). */
5
+ recorded_at: number;
6
+ room_id: string;
7
+ event_id: string;
8
+ type: string;
9
+ sender: string;
10
+ origin_server_ts: number;
11
+ state_key?: string;
12
+ /** The content as the room's members see it: decrypted for end-to-end encrypted events. */
13
+ content: Record<string, unknown>;
14
+ redacts?: string;
15
+ /** The event was end-to-end encrypted. */
16
+ encrypted: boolean;
17
+ /** For encrypted events: the sending device, as the room key says. */
18
+ sender_device?: string;
19
+ /** The event could not be decrypted (no room key reached the bot); `content` is the ciphertext. */
20
+ decryption_error?: string;
21
+ /** Part of the room's state when the bot joined, not a new event. */
22
+ initial_state?: boolean;
23
+ }
24
+ /** Where records go. Writes for one room come in order; a write that throws is retried. */
25
+ export interface AuditSink {
26
+ write(records: AuditRecord[]): Promise<void>;
27
+ }
28
+ /**
29
+ * JSON Lines files: `{directory}/{room ID}/{YYYY-MM-DD}.jsonl`, one file per room and UTC day of
30
+ * the event. Room IDs are percent-encoded, so they cannot name a path outside the directory.
31
+ */
32
+ export declare function fileSink(directory: string): AuditSink;
33
+ /** Records kept in memory (tests, or a custom forwarder that reads `records`). */
34
+ export declare function memorySink(): AuditSink & {
35
+ records: AuditRecord[];
36
+ };
37
+ //#endregion
@@ -0,0 +1,33 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ //#region src/auditbot/sinks.ts
4
+ const day = (ts) => new Date(ts).toISOString().slice(0, 10);
5
+ /**
6
+ * JSON Lines files: `{directory}/{room ID}/{YYYY-MM-DD}.jsonl`, one file per room and UTC day of
7
+ * the event. Room IDs are percent-encoded, so they cannot name a path outside the directory.
8
+ */
9
+ function fileSink(directory) {
10
+ return { async write(records) {
11
+ const byFile = /* @__PURE__ */ new Map();
12
+ for (const r of records) {
13
+ const file = join(directory, encodeURIComponent(r.room_id), `${day(r.origin_server_ts)}.jsonl`);
14
+ byFile.set(file, [...byFile.get(file) ?? [], JSON.stringify(r)]);
15
+ }
16
+ for (const [file, lines] of byFile) {
17
+ await mkdir(join(file, ".."), { recursive: true });
18
+ await appendFile(file, `${lines.join("\n")}\n`);
19
+ }
20
+ } };
21
+ }
22
+ /** Records kept in memory (tests, or a custom forwarder that reads `records`). */
23
+ function memorySink() {
24
+ const records = [];
25
+ return {
26
+ records,
27
+ async write(batch) {
28
+ records.push(...batch);
29
+ }
30
+ };
31
+ }
32
+ //#endregion
33
+ export { fileSink, memorySink };
@@ -53,6 +53,12 @@ export declare class AdminService {
53
53
  }>;
54
54
  listRegistrationTokens(): Promise<Row[]>;
55
55
  deleteRegistrationToken(token: string): Promise<void>;
56
+ /**
57
+ * Make a local user join a room, as Synapse's `POST /_synapse/admin/v1/join` does: public rooms
58
+ * directly; otherwise a local member who may invite invites them first (the invite shows in the
59
+ * room, so its members see who added them). Returns the room ID.
60
+ */
61
+ joinUser(userId: string, roomIdOrAlias: string): Promise<string>;
56
62
  listRooms(opts?: {
57
63
  from?: number;
58
64
  limit?: number;
@@ -2,6 +2,7 @@ import { randomString } from "./crypto.js";
2
2
  import { M } from "./m.js";
3
3
  import { errors } from "./errors.js";
4
4
  import { LOCALPART_RE, isValidUserId } from "./ids.js";
5
+ import { getState, membershipOf, powerLevels, userLevel } from "./authrules.js";
5
6
  import { createUser } from "../api/account.js";
6
7
  //#region src/core/admin.ts
7
8
  /** Programmatic administration, for embedding applications and the admin HTTP API. */
@@ -145,6 +146,30 @@ var AdminService = class {
145
146
  value: token
146
147
  }])) throw errors.notFound("Unknown token");
147
148
  }
149
+ /**
150
+ * Make a local user join a room, as Synapse's `POST /_synapse/admin/v1/join` does: public rooms
151
+ * directly; otherwise a local member who may invite invites them first (the invite shows in the
152
+ * room, so its members see who added them). Returns the room ID.
153
+ */
154
+ async joinUser(userId, roomIdOrAlias) {
155
+ if (!this.hs.isLocalUser(userId) || !await this.getUser(userId)) throw errors.notFound("Unknown user");
156
+ const roomId = await this.hs.rooms.resolveRoomIdOrAlias(roomIdOrAlias);
157
+ const version = await this.hs.rooms.roomVersion(roomId);
158
+ if (!version) throw errors.notFound("Unknown room");
159
+ const state = await this.hs.rooms.currentState(roomId);
160
+ const membership = membershipOf(state, userId);
161
+ if (membership === "join") return roomId;
162
+ if (membership === "ban") throw errors.forbidden("The user is banned from the room");
163
+ const joinRule = getState(state, M.ROOM.JOIN_RULES)?.content?.join_rule;
164
+ if (membership !== "invite" && joinRule !== "public") {
165
+ const pl = powerLevels(state, version);
166
+ const inviter = (await this.hs.rooms.joinedMembers(roomId)).find((m) => this.hs.isLocalUser(m) && userLevel(pl, m) >= pl.invite);
167
+ if (!inviter) throw errors.forbidden("No local member of the room may invite");
168
+ await this.hs.rooms.invite(roomId, inviter, userId);
169
+ }
170
+ await this.hs.rooms.join(userId, roomId);
171
+ return roomId;
172
+ }
148
173
  async listRooms(opts = {}) {
149
174
  const rows = await this.hs.db.findMany("rooms", { sortBy: {
150
175
  field: "created_ts",
@@ -13,20 +13,20 @@ import { Notifier } from "./core/notifier.js";
13
13
  import { RoomService } from "./core/rooms.js";
14
14
  import { TypingService } from "./core/typing.js";
15
15
  import { PushService } from "./core/push.js";
16
- import { AdminService, ServerNoticesOptions } from "./core/admin.js";
17
- import { OAuthDelegation, OAuthDelegationOptions } from "./core/oauth.js";
18
- import { OAuthServer, OAuthServerOptions } from "./core/oauthserver.js";
19
- import { SsoOptions, SsoService } from "./core/sso.js";
20
- import { FederationOptions, FederationService } from "./federation/index.js";
21
- import { DelayedEventService, DelayedEventsOptions } from "./core/delayed.js";
16
+ import { ResolvedUserAuth, UserAuthAdapter } from "./adapters/auth.js";
17
+ import { MediaAdapter, Thumbnailer } from "./adapters/media.js";
22
18
  import { RateLimitAction, RateLimitRule, RateLimiter, RateLimits } from "./core/ratelimit.js";
19
+ import { FederationOptions, FederationService } from "./federation/index.js";
23
20
  import { Router } from "./http/router.js";
24
21
  import { RtcOptions } from "./api/rtc.js";
22
+ import { DelayedEventService, DelayedEventsOptions } from "./core/delayed.js";
23
+ import { OAuthDelegation, OAuthDelegationOptions } from "./core/oauth.js";
24
+ import { OAuthServer, OAuthServerOptions } from "./core/oauthserver.js";
25
+ import { SsoOptions, SsoService } from "./core/sso.js";
25
26
  import { AuditOptions, AuditService } from "./core/audit.js";
26
- import { SettingsService } from "./core/settings.js";
27
27
  import { AdminRoomOptions, AdminRoomService } from "./core/adminroom.js";
28
- import { ResolvedUserAuth, UserAuthAdapter } from "./adapters/auth.js";
29
- import { MediaAdapter, Thumbnailer } from "./adapters/media.js";
28
+ import { AdminService, ServerNoticesOptions } from "./core/admin.js";
29
+ import { SettingsService } from "./core/settings.js";
30
30
  import { ChangeTracker } from "./core/changes.js";
31
31
  import { AddressPolicy } from "./core/outbound.js";
32
32
  import { EventEmitter } from "node:events";
package/dist/index.d.ts CHANGED
@@ -3,25 +3,25 @@ import { M, UNSTABLE } from "./core/m.js";
3
3
  import { AppServiceRegistration, loadAppServiceRegistrations, parseAppServiceRegistration } from "./core/appservices.js";
4
4
  import { DatabaseAdapter, FieldDef, FindManyOptions, ModelDef, Operator, Row, Schema, WhereClause } from "./storage/types.js";
5
5
  import { ClusterAdapter, localCluster } from "./core/cluster.js";
6
- import { AdminService, ServerNoticesOptions } from "./core/admin.js";
7
- import { OAuthDelegationOptions } from "./core/oauth.js";
8
- import { OAuthServerOptions } from "./core/oauthserver.js";
9
- import { SsoOptions, SsoProvider, githubProvider } from "./core/sso.js";
10
- import { MatrixError } from "./core/errors.js";
11
- import { FederationOptions } from "./federation/index.js";
12
- import { DelayedEventsOptions } from "./core/delayed.js";
13
- import { RateLimitAction, RateLimitRule, RateLimiter, memoryRateLimiter } from "./core/ratelimit.js";
14
- import { RtcOptions, TurnOptions } from "./api/rtc.js";
15
- import { AuditAction, AuditEntry, AuditOptions } from "./core/audit.js";
16
- import { AdminRoomOptions } from "./core/adminroom.js";
17
- import { PasswordCheckContext, UserAuthAdapter } from "./adapters/auth.js";
18
- import { MediaAdapter, MediaMetadata, ThumbnailRequest, Thumbnailer, filesystemMedia, memoryMedia, sharpThumbnailer } from "./adapters/media.js";
19
- import { Homeserver, HomeserverOptions, Logger, createHomeserver } from "./homeserver.js";
20
6
  import { NodeHandler, getExpressHandler, getFastifyPlugin, getFetchHandler, getHonoHandler, getNodeHandler, isMatrixPath } from "./http/adapters.js";
21
7
  import { handleRequest } from "./http/handler.js";
8
+ import { MatrixError } from "./core/errors.js";
22
9
  import { memoryAdapter } from "./storage/memory.js";
23
10
  import { MysqlPoolLike, PgPoolLike, SqlAdapterOptions, SqlDialect, SqlExecutor, SqliteDatabaseLike, mysqlAdapter, postgresAdapter, sqlAdapter, sqliteAdapter } from "./storage/sql.js";
24
11
  import { schema } from "./storage/schema.js";
12
+ import { PasswordCheckContext, UserAuthAdapter } from "./adapters/auth.js";
13
+ import { MediaAdapter, MediaMetadata, ThumbnailRequest, Thumbnailer, filesystemMedia, memoryMedia, sharpThumbnailer } from "./adapters/media.js";
25
14
  import { S3MediaOptions, s3Media } from "./adapters/s3.js";
15
+ import { RateLimitAction, RateLimitRule, RateLimiter, memoryRateLimiter } from "./core/ratelimit.js";
26
16
  import { PgClusterPoolLike, postgresCluster } from "./adapters/cluster.js";
17
+ import { FederationOptions } from "./federation/index.js";
18
+ import { RtcOptions, TurnOptions } from "./api/rtc.js";
19
+ import { DelayedEventsOptions } from "./core/delayed.js";
20
+ import { OAuthDelegationOptions } from "./core/oauth.js";
21
+ import { OAuthServerOptions } from "./core/oauthserver.js";
22
+ import { SsoOptions, SsoProvider, githubProvider } from "./core/sso.js";
23
+ import { AuditAction, AuditEntry, AuditOptions } from "./core/audit.js";
24
+ import { AdminRoomOptions } from "./core/adminroom.js";
25
+ import { AdminService, ServerNoticesOptions } from "./core/admin.js";
26
+ import { Homeserver, HomeserverOptions, Logger, createHomeserver } from "./homeserver.js";
27
27
  export { type AdminRoomOptions, type AdminService, type AppServiceRegistration, type AuditAction, type AuditEntry, type AuditOptions, type ClusterAdapter, type DatabaseAdapter, type DelayedEventsOptions, type FederationOptions, type FieldDef, type FindManyOptions, Homeserver, type HomeserverOptions, type Logger, M, MatrixError, type MediaAdapter, type MediaMetadata, type ModelDef, type MysqlPoolLike, type NodeHandler, type OAuthDelegationOptions, type OAuthServerOptions, type Operator, type PasswordCheckContext, type PgClusterPoolLike, type PgPoolLike, type RateLimitAction, type RateLimitRule, type RateLimiter, type Row, type RtcOptions, type S3MediaOptions, type Schema, type ServerNoticesOptions, type SqlAdapterOptions, type SqlDialect, type SqlExecutor, type SqliteDatabaseLike, type SsoOptions, type SsoProvider, type ThreepidEmail, type ThreepidOptions, type ThreepidPurpose, type ThreepidSms, type ThumbnailRequest, type Thumbnailer, type TurnOptions, UNSTABLE, type UserAuthAdapter, type WhereClause, createHomeserver, filesystemMedia, getExpressHandler, getFastifyPlugin, getFetchHandler, getHonoHandler, getNodeHandler, githubProvider, handleRequest, isMatrixPath, loadAppServiceRegistrations, localCluster, memoryAdapter, memoryMedia, memoryRateLimiter, mysqlAdapter, parseAppServiceRegistration, postgresAdapter, postgresCluster, s3Media, schema, sharpThumbnailer, sqlAdapter, sqliteAdapter };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "matrix-nodejs-homeserver",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "A framework- and database-agnostic Matrix homeserver library for Node.js",
5
5
  "keywords": [
6
6
  "chat",
@@ -20,9 +20,10 @@
20
20
  "directory": "packages/core"
21
21
  },
22
22
  "files": [
23
- "dist",
23
+ "AUDITBOT.md",
24
+ "LICENSE",
24
25
  "README.md",
25
- "LICENSE"
26
+ "dist"
26
27
  ],
27
28
  "type": "module",
28
29
  "main": "./dist/index.js",
@@ -34,6 +35,10 @@
34
35
  ".": {
35
36
  "types": "./dist/index.d.ts",
36
37
  "default": "./dist/index.js"
38
+ },
39
+ "./auditbot": {
40
+ "types": "./dist/auditbot/index.d.ts",
41
+ "default": "./dist/auditbot/index.js"
37
42
  }
38
43
  },
39
44
  "publishConfig": {
@@ -56,12 +61,21 @@
56
61
  "@better-auth/oauth-provider": "1.7.6",
57
62
  "@better-auth/utils": "0.4.2",
58
63
  "@better-fetch/fetch": "1.3.2",
64
+ "@matrix-org/matrix-sdk-crypto-nodejs": "0.6.6",
59
65
  "better-auth": "1.7.6",
60
66
  "better-call": "1.4.0",
61
67
  "publint": "^0.3.25",
62
68
  "tsdown": "^0.23.1",
63
69
  "zod": "^4.6.7"
64
70
  },
71
+ "peerDependencies": {
72
+ "@matrix-org/matrix-sdk-crypto-nodejs": "^0.6.6"
73
+ },
74
+ "peerDependenciesMeta": {
75
+ "@matrix-org/matrix-sdk-crypto-nodejs": {
76
+ "optional": true
77
+ }
78
+ },
65
79
  "engines": {
66
80
  "node": ">=22"
67
81
  }