matrix-nodejs-homeserver 0.3.4 → 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.
Files changed (55) hide show
  1. package/AUDITBOT.md +67 -0
  2. package/README.md +55 -9
  3. package/dist/api/account.js +109 -64
  4. package/dist/api/admin.js +14 -0
  5. package/dist/api/federation.js +1 -1
  6. package/dist/api/index.js +2 -0
  7. package/dist/api/misc.js +3 -3
  8. package/dist/api/oauthserver.js +620 -0
  9. package/dist/api/sync.js +1 -1
  10. package/dist/api/threepid.js +1 -1
  11. package/dist/api/uia.js +20 -8
  12. package/dist/api/user.js +6 -3
  13. package/dist/auditbot/bot.d.ts +37 -0
  14. package/dist/auditbot/bot.js +208 -0
  15. package/dist/auditbot/client.d.ts +41 -0
  16. package/dist/auditbot/client.js +156 -0
  17. package/dist/auditbot/index.d.ts +4 -0
  18. package/dist/auditbot/index.js +4 -0
  19. package/dist/auditbot/sinks.d.ts +37 -0
  20. package/dist/auditbot/sinks.js +33 -0
  21. package/dist/core/admin.d.ts +6 -0
  22. package/dist/core/admin.js +25 -0
  23. package/dist/core/auth.d.ts +33 -2
  24. package/dist/core/auth.js +84 -21
  25. package/dist/core/oauth.js +2 -1
  26. package/dist/core/oauthserver.d.ts +141 -0
  27. package/dist/core/oauthserver.js +603 -0
  28. package/dist/core/ratelimit.d.ts +1 -1
  29. package/dist/core/ratelimit.js +4 -0
  30. package/dist/core/sso.d.ts +51 -15
  31. package/dist/core/sso.js +86 -26
  32. package/dist/homeserver.d.ts +20 -9
  33. package/dist/homeserver.js +17 -2
  34. package/dist/index.d.ts +15 -14
  35. package/dist/index.js +2 -1
  36. package/dist/storage/schema.js +20 -2
  37. package/dist/vendor/zod/v4/classic/errors.js +1 -1
  38. package/dist/vendor/zod/v4/classic/parse.js +1 -1
  39. package/dist/vendor/zod/v4/classic/schemas.js +1 -1
  40. package/dist/vendor/zod/v4/core/api.js +1 -1
  41. package/dist/vendor/zod/v4/core/checks.js +1 -1
  42. package/dist/vendor/zod/v4/core/core.js +1 -1
  43. package/dist/vendor/zod/v4/core/doc.js +1 -1
  44. package/dist/vendor/zod/v4/core/errors.js +1 -1
  45. package/dist/vendor/zod/v4/core/json-schema-processors.js +1 -1
  46. package/dist/vendor/zod/v4/core/memoizer.js +1 -1
  47. package/dist/vendor/zod/v4/core/parse.js +1 -1
  48. package/dist/vendor/zod/v4/core/regexes.js +1 -1
  49. package/dist/vendor/zod/v4/core/registries.js +1 -1
  50. package/dist/vendor/zod/v4/core/schemas.js +1 -1
  51. package/dist/vendor/zod/v4/core/to-json-schema.js +1 -1
  52. package/dist/vendor/zod/v4/core/util.js +1 -1
  53. package/dist/vendor/zod/v4/core/versions.js +1 -1
  54. package/dist/vendor/zod/v4/locales/en.js +1 -1
  55. package/package.json +28 -17
package/dist/api/uia.js CHANGED
@@ -2,15 +2,26 @@ import { isObject } from "../core/json.js";
2
2
  import { randomString } from "../core/crypto.js";
3
3
  import { M, UNSTABLE } from "../core/m.js";
4
4
  import { MatrixError, errors } from "../core/errors.js";
5
+ import { crossSigningResetAllowed } from "../core/oauthserver.js";
5
6
  //#region src/api/uia.ts
6
7
  const UIA_TTL_MS = 6e5;
7
8
  /**
8
9
  * User-interactive authentication. Resolves when one of `flows` has been
9
10
  * completed; otherwise throws the 401 response describing what is required.
10
- * `userId` binds password stages to an already-authenticated user.
11
+ * `userId` binds password stages to an already-authenticated user. `session` is the caller's,
12
+ * and `crossSigningReset` marks `/keys/device_signing/upload`, the one endpoint where tokens of
13
+ * the OAuth 2.0 API may use UIA (the `m.oauth` stage).
11
14
  */
12
- async function requireUia(hs, body, flows, userId) {
13
- if (hs.oauth && flows.some((f) => f.includes(M.LOGIN.PASSWORD))) flows = [[M.OAUTH], [UNSTABLE.ORG.MATRIX.CROSS_SIGNING_RESET]];
15
+ async function requireUia(hs, body, flows, userId, opts = {}) {
16
+ const viaOAuth = !!opts.session?.oauth || !!hs.oauth && flows.some((f) => f.includes(M.LOGIN.PASSWORD));
17
+ let oauthUrl;
18
+ if (viaOAuth) {
19
+ const accountUri = hs.oauthServer?.accountManagementUri ?? hs.oauth?.options.accountManagementUrl;
20
+ if (!opts.crossSigningReset) throw new MatrixError(403, "M_FORBIDDEN", "Do this on the account management page", accountUri ? { account_management_uri: accountUri } : {});
21
+ if (userId && await crossSigningResetAllowed(hs, userId)) return { threepids: [] };
22
+ flows = [[M.OAUTH], [UNSTABLE.ORG.MATRIX.CROSS_SIGNING_RESET]];
23
+ oauthUrl = hs.oauthServer ? `${hs.oauthServer.accountManagementUri}?action=org.matrix.cross_signing_reset` : hs.oauth.options.accountManagementUrl ?? hs.oauth.options.issuer;
24
+ }
14
25
  const auth = isObject(body.auth) ? body.auth : null;
15
26
  let sessionId = typeof auth?.session === "string" ? auth.session : void 0;
16
27
  const stored = sessionId ? await hs.cluster.get(`uia:${sessionId}`) : null;
@@ -25,8 +36,7 @@ async function requireUia(hs, body, flows, userId) {
25
36
  userId: session.userId,
26
37
  threepids: session.threepids
27
38
  }, UIA_TTL_MS);
28
- const oauthUrl = hs.oauth?.options.accountManagementUrl ?? hs.oauth?.options.issuer;
29
- const params = hs.oauth ? {
39
+ const params = oauthUrl ? {
30
40
  [M.OAUTH]: { url: oauthUrl },
31
41
  [UNSTABLE.ORG.MATRIX.CROSS_SIGNING_RESET]: { url: oauthUrl }
32
42
  } : {};
@@ -49,12 +59,14 @@ async function requireUia(hs, body, flows, userId) {
49
59
  break;
50
60
  case M.OAUTH:
51
61
  case UNSTABLE.ORG.MATRIX.CROSS_SIGNING_RESET:
52
- if (!hs.oauth) throw challenge({
62
+ if (!viaOAuth) throw challenge({
53
63
  errcode: "M_UNRECOGNIZED",
54
64
  error: "OAuth is not enabled"
55
65
  });
56
- session.completed.add(auth.type);
57
- break;
66
+ throw challenge({
67
+ errcode: "M_FORBIDDEN",
68
+ error: "The reset has not been approved on the account page"
69
+ });
58
70
  case M.LOGIN.REGISTRATION_TOKEN: {
59
71
  const { registrationTokenValid } = await import("./account.js");
60
72
  if (typeof auth.token !== "string" || !await registrationTokenValid(hs, auth.token)) throw challenge({
package/dist/api/user.js CHANGED
@@ -185,7 +185,7 @@ function registerUserRoutes(r) {
185
185
  value: ctx.params.deviceId
186
186
  }];
187
187
  if (!await ctx.hs.db.findOne("devices", where)) throw errors.notFound("Device not found");
188
- await requireUia(ctx.hs, body, [[M.LOGIN.PASSWORD]], s.userId);
188
+ await requireUia(ctx.hs, body, [[M.LOGIN.PASSWORD]], s.userId, { session: s });
189
189
  await ctx.hs.auth.deleteDevice(s.userId, ctx.params.deviceId);
190
190
  return {};
191
191
  }
@@ -196,7 +196,7 @@ function registerUserRoutes(r) {
196
196
  handler: async (ctx) => {
197
197
  const s = ctx.session;
198
198
  const body = ctx.input;
199
- await requireUia(ctx.hs, body, [[M.LOGIN.PASSWORD]], s.userId);
199
+ await requireUia(ctx.hs, body, [[M.LOGIN.PASSWORD]], s.userId, { session: s });
200
200
  for (const d of body.devices) if (typeof d === "string") await ctx.hs.auth.deleteDevice(s.userId, d);
201
201
  return {};
202
202
  }
@@ -275,7 +275,10 @@ function registerUserRoutes(r) {
275
275
  "self_signing",
276
276
  "user_signing"
277
277
  ].filter((type) => body[`${type}_key`] !== void 0).every((type) => stored.get(type) === canonicalJson(body[`${type}_key`]))) return {};
278
- await requireUia(ctx.hs, body, [[M.LOGIN.PASSWORD]], s.userId);
278
+ await requireUia(ctx.hs, body, [[M.LOGIN.PASSWORD]], s.userId, {
279
+ session: s,
280
+ crossSigningReset: true
281
+ });
279
282
  }
280
283
  await ctx.hs.keys.uploadCrossSigning(s.userId, body);
281
284
  return {};
@@ -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",