@vex-chat/spire 0.7.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/README.md +82 -26
  2. package/dist/ClientManager.d.ts +24 -25
  3. package/dist/ClientManager.js +232 -509
  4. package/dist/ClientManager.js.map +1 -0
  5. package/dist/Database.d.ts +49 -41
  6. package/dist/Database.js +698 -716
  7. package/dist/Database.js.map +1 -0
  8. package/dist/Spire.d.ts +23 -15
  9. package/dist/Spire.js +518 -218
  10. package/dist/Spire.js.map +1 -0
  11. package/dist/__tests__/Database.spec.js +113 -73
  12. package/dist/__tests__/Database.spec.js.map +1 -0
  13. package/dist/db/schema.d.ts +134 -0
  14. package/dist/db/schema.js +2 -0
  15. package/dist/db/schema.js.map +1 -0
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.js +3 -5
  18. package/dist/index.js.map +1 -0
  19. package/dist/middleware/validate.d.ts +12 -0
  20. package/dist/middleware/validate.js +35 -0
  21. package/dist/middleware/validate.js.map +1 -0
  22. package/dist/migrations/2026-04-06_initial-schema.d.ts +3 -0
  23. package/dist/migrations/2026-04-06_initial-schema.js +192 -0
  24. package/dist/migrations/2026-04-06_initial-schema.js.map +1 -0
  25. package/dist/run.js +26 -21
  26. package/dist/run.js.map +1 -0
  27. package/dist/server/avatar.d.ts +3 -4
  28. package/dist/server/avatar.js +85 -67
  29. package/dist/server/avatar.js.map +1 -0
  30. package/dist/server/errors.d.ts +59 -0
  31. package/dist/server/errors.js +94 -0
  32. package/dist/server/errors.js.map +1 -0
  33. package/dist/server/file.d.ts +3 -4
  34. package/dist/server/file.js +101 -61
  35. package/dist/server/file.js.map +1 -0
  36. package/dist/server/index.d.ts +9 -6
  37. package/dist/server/index.js +595 -70
  38. package/dist/server/index.js.map +1 -0
  39. package/dist/server/invite.d.ts +4 -5
  40. package/dist/server/invite.js +21 -103
  41. package/dist/server/invite.js.map +1 -0
  42. package/dist/server/openapi.d.ts +2 -0
  43. package/dist/server/openapi.js +40 -0
  44. package/dist/server/openapi.js.map +1 -0
  45. package/dist/server/permissions.d.ts +16 -0
  46. package/dist/server/permissions.js +22 -0
  47. package/dist/server/permissions.js.map +1 -0
  48. package/dist/server/rateLimit.d.ts +28 -0
  49. package/dist/server/rateLimit.js +58 -0
  50. package/dist/server/rateLimit.js.map +1 -0
  51. package/dist/server/user.d.ts +4 -7
  52. package/dist/server/user.js +66 -76
  53. package/dist/server/user.js.map +1 -0
  54. package/dist/server/utils.d.ts +35 -7
  55. package/dist/server/utils.js +50 -6
  56. package/dist/server/utils.js.map +1 -0
  57. package/dist/types/express.d.ts +20 -0
  58. package/dist/types/express.js +2 -0
  59. package/dist/types/express.js.map +1 -0
  60. package/dist/utils/createLogger.js +13 -19
  61. package/dist/utils/createLogger.js.map +1 -0
  62. package/dist/utils/createUint8UUID.js +6 -10
  63. package/dist/utils/createUint8UUID.js.map +1 -0
  64. package/dist/utils/jwtSecret.d.ts +7 -0
  65. package/dist/utils/jwtSecret.js +15 -0
  66. package/dist/utils/jwtSecret.js.map +1 -0
  67. package/dist/utils/loadEnv.js +7 -22
  68. package/dist/utils/loadEnv.js.map +1 -0
  69. package/dist/utils/msgpack.d.ts +2 -0
  70. package/dist/utils/msgpack.js +4 -0
  71. package/dist/utils/msgpack.js.map +1 -0
  72. package/package.json +91 -63
  73. package/src/ClientManager.ts +434 -0
  74. package/src/Database.ts +925 -0
  75. package/src/Spire.ts +878 -0
  76. package/src/__tests__/Database.spec.ts +167 -0
  77. package/src/ambient-modules.d.ts +1 -0
  78. package/src/db/schema.ts +165 -0
  79. package/src/index.ts +3 -0
  80. package/src/middleware/validate.ts +38 -0
  81. package/src/migrations/2026-04-06_initial-schema.ts +218 -0
  82. package/src/run.ts +37 -0
  83. package/src/server/avatar.ts +141 -0
  84. package/src/server/errors.ts +133 -0
  85. package/src/server/file.ts +172 -0
  86. package/src/server/index.ts +855 -0
  87. package/src/server/invite.ts +65 -0
  88. package/src/server/openapi.ts +51 -0
  89. package/src/server/permissions.ts +40 -0
  90. package/src/server/rateLimit.ts +86 -0
  91. package/src/server/user.ts +125 -0
  92. package/src/server/utils.ts +59 -0
  93. package/src/types/express.ts +23 -0
  94. package/src/utils/createLogger.ts +47 -0
  95. package/src/utils/createUint8UUID.ts +9 -0
  96. package/src/utils/jwtSecret.ts +16 -0
  97. package/src/utils/loadEnv.ts +15 -0
  98. package/src/utils/msgpack.ts +4 -0
  99. package/avatars/169d76cb-6e7c-4e24-8224-017673eed8ff +0 -0
  100. package/avatars/1cae8d0b-0c6b-4c73-b25c-2d2349a57122 +0 -0
  101. package/avatars/1d87bc2b-71fc-4818-8004-40d04093e5fc +0 -0
  102. package/avatars/1f2c3d62-8b4d-465a-9895-51a24caef00d +0 -0
  103. package/avatars/245ee7fc-1004-41ab-adab-a04c9ceb9d7a +0 -0
  104. package/avatars/2465c28c-bdaf-4fa2-b42a-d054f04dc39b +0 -0
  105. package/avatars/3900a674-a2dd-4996-a61d-8c29b3270f41 +0 -0
  106. package/avatars/3c3b9c77-ea50-45e7-bb25-65d6f3d2a219 +0 -0
  107. package/avatars/414a2ff4-ad2f-4a7d-aa27-3b09ad3522b2 +0 -0
  108. package/avatars/522fe504-f0ad-4ed4-9dc6-e5dc2338e531 +0 -0
  109. package/avatars/53e5eb29-e7d1-4d58-add9-d44f39f0cfb7 +0 -0
  110. package/avatars/54d3f757-1038-41c8-bfb9-efd37b6e8ebe +0 -0
  111. package/avatars/623e86d7-c49c-46f6-9b76-ca70c9dbc43b +0 -0
  112. package/avatars/66e2abae-60f5-4dd1-b9fb-297a4bedfeb0 +0 -0
  113. package/avatars/6f37980e-f6fa-4d6d-9206-24050b403f45 +0 -0
  114. package/avatars/80138ece-eb5c-4b20-817b-903d6b0f54ae +0 -0
  115. package/avatars/841c77d3-37c4-431b-be22-672888062874 +0 -0
  116. package/avatars/88051a61-2bda-4750-95a3-5fb0b4918149 +0 -0
  117. package/avatars/89540973-c421-4bf1-8b89-9f1eaa51c1b5 +0 -0
  118. package/avatars/8a802fad-8c99-4942-8f80-47cec600149c +0 -0
  119. package/avatars/90531d8a-907a-4a1a-ac45-c85e4acb0df9 +0 -0
  120. package/avatars/9b7d0da9-b8d6-4801-b128-9993f79f464a +0 -0
  121. package/avatars/9bc456f1-c4c4-48a1-b9e6-fd44dd744a72 +0 -0
  122. package/avatars/9cf878bf-7430-49ec-a47a-78f3c93793dd +0 -0
  123. package/avatars/9ee82847-6ad3-45e5-92b9-f474a6c54d96 +0 -0
  124. package/avatars/ab44c857-d81d-4c88-85db-32f9532e5376 +0 -0
  125. package/avatars/b396a8d2-dc14-48d2-aac4-3755dc637051 +0 -0
  126. package/avatars/b6ac11c5-a8b2-4e0a-995a-9c87f1e58787 +0 -0
  127. package/avatars/b79d6855-b738-434c-be32-809637e62b9b +0 -0
  128. package/avatars/bbcd0188-d6a5-48ae-90fb-be5ff30599ab +0 -0
  129. package/avatars/bc7a9e0e-4720-4a6e-a90d-c11fec94d380 +0 -0
  130. package/avatars/c1c4889f-8383-4041-8bdd-9fded4046f37 +0 -0
  131. package/avatars/c4c7203c-d93a-4749-ade2-17053acf1d2a +0 -0
  132. package/avatars/ca974a70-0a23-4668-8b80-c4304dc7f793 +0 -0
  133. package/avatars/cf119a0d-eb3f-4bed-905b-f14a876c3535 +0 -0
  134. package/avatars/d464b03d-30c2-49e3-a666-80aefa8a1b35 +0 -0
  135. package/avatars/da0eee89-82f0-4d45-ab48-7d2786b634c5 +0 -0
  136. package/avatars/de4c77a5-68e9-4bb2-b40d-d964bf377d61 +0 -0
  137. package/avatars/dea95395-7d0b-42aa-a9ed-40c7d4fb4c48 +0 -0
  138. package/avatars/edb30749-59ba-4aa2-9c52-0fb22048f4cf +0 -0
  139. package/avatars/f17c245a-af7d-445b-9365-49f7f54b1eeb +0 -0
  140. package/avatars/f1ee6a35-b262-4dbf-99f5-3d011e3b98ec +0 -0
  141. package/avatars/f802bdd0-345d-41f6-9184-0f30e1258fb3 +0 -0
  142. package/dist/migrations/20210103192527_users.d.ts +0 -3
  143. package/dist/migrations/20210103192527_users.js +0 -30
  144. package/dist/migrations/20210103193502_mail.d.ts +0 -3
  145. package/dist/migrations/20210103193502_mail.js +0 -35
  146. package/dist/migrations/20210103193525_preKeys.d.ts +0 -3
  147. package/dist/migrations/20210103193525_preKeys.js +0 -30
  148. package/dist/migrations/20210103193553_oneTimeKeys.d.ts +0 -3
  149. package/dist/migrations/20210103193553_oneTimeKeys.js +0 -30
  150. package/dist/migrations/20210103193615_servers.d.ts +0 -3
  151. package/dist/migrations/20210103193615_servers.js +0 -28
  152. package/dist/migrations/20210103193729_channels.d.ts +0 -3
  153. package/dist/migrations/20210103193729_channels.js +0 -28
  154. package/dist/migrations/20210103193749_permissions.d.ts +0 -3
  155. package/dist/migrations/20210103193749_permissions.js +0 -30
  156. package/dist/migrations/20210103193801_files.d.ts +0 -3
  157. package/dist/migrations/20210103193801_files.js +0 -28
  158. package/files/00ea7368-45a7-4f6a-a199-974da14be1a0 +0 -0
  159. package/files/039503d2-a170-4962-b921-c97994ba64ff +0 -0
  160. package/files/14b6fa02-4cbb-40df-be4f-a07187cb619e +0 -0
  161. package/files/15c04cb1-dc6a-4f19-aa6f-4a3b92d05bf7 +0 -0
  162. package/files/2a8d411c-8b92-4532-b84d-d64c638d6293 +0 -0
  163. package/files/37de2cd3-08a8-4044-9c37-e13386765f3d +0 -0
  164. package/files/42452029-284e-4c81-9f18-feb6ce309eed +0 -0
  165. package/files/43d09f2f-29c8-415f-8c4a-23f2a32eb79e +0 -0
  166. package/files/52992923-33ab-44a1-8118-605e9b4856a7 +0 -0
  167. package/files/53180681-36e2-49c0-8382-94dca0da09bf +0 -0
  168. package/files/5a56cd7b-1d04-4619-b60f-1e5515b9164a +0 -0
  169. package/files/5ced7676-20c4-4219-a4f2-70a25eb7eea8 +0 -0
  170. package/files/60e787ff-8ec5-444d-b963-0aaf5313b53e +0 -0
  171. package/files/67a17729-fcb7-4339-9499-1fc08fea72ca +0 -0
  172. package/files/68d09565-908d-4a67-8f09-e183f8708eb4 +0 -0
  173. package/files/70e587c5-56e8-47f0-bd36-691efcb0cc2e +0 -4
  174. package/files/7a227619-b715-4e2c-a79d-b2158d56a799 +0 -0
  175. package/files/7e3cc3ea-b706-4835-994d-65d33acaf369 +0 -0
  176. package/files/816f72a0-65dc-40c1-8862-1d22065cca3c +0 -0
  177. package/files/8bf84972-5086-4631-a752-093d7b1a098b +0 -0
  178. package/files/8c46e3bc-3f2e-441d-b8cc-17428fc3d219 +0 -0
  179. package/files/8eb83364-8826-4eee-895a-ba5cd3ab85e9 +0 -0
  180. package/files/8faefaea-14e3-49e4-ac74-9710622bfae9 +0 -0
  181. package/files/91af08c2-9dca-41f4-b6c6-b7bf33505df9 +0 -0
  182. package/files/9349dffa-35dd-49a0-a651-10b438038f95 +0 -0
  183. package/files/b0a12a41-6283-4f27-b2c8-66774991d96c +0 -0
  184. package/files/b28afb08-d18b-48a8-93c3-6a59c66d8fee +0 -0
  185. package/files/be60fe01-1578-4363-a908-41500092b77d +0 -0
  186. package/files/cf7b90e3-734a-4453-9ff3-9a331404b5ef +0 -0
  187. package/files/d1be30aa-8ff4-41c1-b360-f65922029047 +0 -0
  188. package/files/d2d31a57-a413-443c-9b97-fa9ca394ef0b +0 -0
  189. package/files/d357f223-b786-478d-a113-b53cb62acdab +0 -0
  190. package/files/d4a69ee0-05c4-4ff0-8753-624cdd0f24e9 +0 -0
  191. package/files/d57f411b-1874-4f00-a6b0-2f86de6b229d +0 -0
  192. package/files/d85aaa33-7f70-4a70-b3d2-cad667beb38c +0 -0
  193. package/files/db2fc236-dbe0-4d24-bfaf-884829fd090a +0 -0
  194. package/files/df2e20ec-6dcc-4402-94ee-7d1163f84197 +0 -0
  195. package/files/e0880ab5-6d49-4dc0-a5ec-0d3793a8a7b8 +0 -0
  196. package/files/e59ee9c6-5aea-48ea-b3d9-94a324dc2260 +0 -0
  197. package/files/e6d7aad6-4220-4ce7-85f8-89d0b1f0cc89 +0 -0
  198. package/files/f0bfc98c-3534-459d-931a-7c6e77bde153 +0 -0
  199. package/files/f8443740-050c-4714-9bf6-334783ae6ffd +0 -0
  200. package/files/fc405ae7-f9fb-455d-ac8c-0ca0d88d4115 +0 -0
  201. package/jest.config.js +0 -13
  202. package/spire.sqlite +0 -0
@@ -0,0 +1,925 @@
1
+ import type { ServerDatabase } from "./db/schema.ts";
2
+ import type { SpireOptions } from "./Spire.ts";
3
+ import type {
4
+ Channel,
5
+ Device,
6
+ DevicePayload,
7
+ Emoji,
8
+ FileSQL,
9
+ Invite,
10
+ KeyBundle,
11
+ MailSQL,
12
+ MailWS,
13
+ Permission,
14
+ PreKeysSQL,
15
+ PreKeysWS,
16
+ RegistrationPayload,
17
+ Server,
18
+ UserRecord,
19
+ } from "@vex-chat/types";
20
+ import type { Migration, MigrationProvider } from "kysely";
21
+ import type winston from "winston";
22
+
23
+ import { EventEmitter } from "events";
24
+ import { pbkdf2Sync } from "node:crypto";
25
+ import * as fs from "node:fs/promises";
26
+ import path from "node:path";
27
+ import { fileURLToPath, pathToFileURL } from "node:url";
28
+
29
+ import { xMakeNonce, XUtils } from "@vex-chat/crypto";
30
+ import { MailType } from "@vex-chat/types";
31
+
32
+ /**
33
+ * Narrow a plain integer from the `mailType` SQL column to the
34
+ * `MailType` union (0 = initial, 1 = subsequent). Throws if the
35
+ * database contains an unexpected value, catching row corruption
36
+ * at read time instead of propagating an invalid literal into
37
+ * application code.
38
+ */
39
+ function parseMailType(n: number): MailType {
40
+ if (n === MailType.initial) return MailType.initial;
41
+ if (n === MailType.subsequent) return MailType.subsequent;
42
+ throw new Error(`Invalid mailType in database row: ${String(n)}`);
43
+ }
44
+
45
+ import BetterSqlite3 from "better-sqlite3";
46
+ import { Kysely, Migrator, sql, SqliteDialect } from "kysely";
47
+ import { stringify as uuidStringify, validate as uuidValidate } from "uuid";
48
+
49
+ import { createLogger } from "./utils/createLogger.ts";
50
+
51
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
52
+ const migrationFolder = path.join(__dirname, "migrations");
53
+
54
+ /**
55
+ * Cross-platform Kysely migration provider.
56
+ *
57
+ * Replaces Kysely's built-in `FileMigrationProvider`, which on Windows
58
+ * fails with `ERR_UNSUPPORTED_ESM_URL_SCHEME` because it does
59
+ * `await import(joinedPath)` where `joinedPath` is a Windows absolute
60
+ * path like `D:\\spire\\src\\migrations\\schema.ts`. Node's ESM loader
61
+ * requires `file://` URLs for absolute paths on Windows.
62
+ *
63
+ * This implementation uses `pathToFileURL` to convert each migration
64
+ * file's absolute path to a `file://` URL before passing it to
65
+ * `import()`. Works on Linux, macOS, and Windows. Filters out `.d.ts`
66
+ * declaration files and accepts both `.ts` and `.js` source files for
67
+ * spire's `--experimental-strip-types` runtime.
68
+ */
69
+ class CrossPlatformMigrationProvider implements MigrationProvider {
70
+ private readonly folder: string;
71
+
72
+ constructor(folder: string) {
73
+ this.folder = folder;
74
+ }
75
+
76
+ async getMigrations(): Promise<Record<string, Migration>> {
77
+ const files = await fs.readdir(this.folder);
78
+ const migrations: Record<string, Migration> = {};
79
+ for (const file of files.sort()) {
80
+ if (file.endsWith(".d.ts")) continue;
81
+ if (!file.endsWith(".ts") && !file.endsWith(".js")) continue;
82
+ const fullPath = path.join(this.folder, file);
83
+ const fileUrl = pathToFileURL(fullPath).href;
84
+ const mod: unknown = await import(fileUrl);
85
+ if (!isMigration(mod)) {
86
+ throw new Error(
87
+ `Invalid migration ${file}: expected an exported \`up\` function`,
88
+ );
89
+ }
90
+ const name = file.replace(/\.(ts|js)$/, "");
91
+ migrations[name] = mod;
92
+ }
93
+ return migrations;
94
+ }
95
+ }
96
+
97
+ function isMigration(mod: unknown): mod is Migration {
98
+ return (
99
+ typeof mod === "object" &&
100
+ mod !== null &&
101
+ "up" in mod &&
102
+ typeof (mod as { up: unknown }).up === "function"
103
+ );
104
+ }
105
+
106
+ const pubkeyRegex = /[0-9a-f]{64}/;
107
+ export const ITERATIONS = 1000;
108
+
109
+ // ── Row-to-interface converters ─────────────────────────────────────────
110
+ // SQLite stores booleans as integers and dates as strings, but the
111
+ // @vex-chat/types interfaces expect boolean / Date.
112
+
113
+ export class Database extends EventEmitter {
114
+ private db: Kysely<ServerDatabase>;
115
+ private log: winston.Logger;
116
+
117
+ constructor(options?: SpireOptions) {
118
+ super();
119
+
120
+ this.log = createLogger("spire-db", options?.logLevel || "error");
121
+
122
+ const dbType = options?.dbType || "sqlite3";
123
+
124
+ let filename: string;
125
+ switch (dbType) {
126
+ case "sqlite":
127
+ case "sqlite3":
128
+ filename = "spire.sqlite";
129
+ break;
130
+ case "sqlite3mem":
131
+ filename = ":memory:";
132
+ break;
133
+ default:
134
+ filename = "spire.sqlite";
135
+ break;
136
+ }
137
+
138
+ const sqliteDb = new BetterSqlite3(filename);
139
+ sqliteDb.pragma("journal_mode = WAL");
140
+ sqliteDb.pragma("synchronous = NORMAL");
141
+ sqliteDb.pragma("busy_timeout = 5000");
142
+ sqliteDb.pragma("cache_size = -64000");
143
+ sqliteDb.pragma("temp_store = memory");
144
+ sqliteDb.pragma("foreign_keys = ON");
145
+
146
+ this.db = new Kysely<ServerDatabase>({
147
+ dialect: new SqliteDialect({ database: sqliteDb }),
148
+ });
149
+
150
+ void this.init();
151
+ }
152
+
153
+ public async close(): Promise<void> {
154
+ this.log.info("Closing database.");
155
+ await this.db.destroy();
156
+ }
157
+
158
+ public async createChannel(
159
+ name: string,
160
+ serverID: string,
161
+ ): Promise<Channel> {
162
+ const channel: Channel = {
163
+ channelID: crypto.randomUUID(),
164
+ name,
165
+ serverID,
166
+ };
167
+ await this.db.insertInto("channels").values(channel).execute();
168
+ return channel;
169
+ }
170
+
171
+ public async createDevice(
172
+ owner: string,
173
+ payload: DevicePayload,
174
+ ): Promise<Device> {
175
+ const device = {
176
+ deleted: 0,
177
+ deviceID: crypto.randomUUID(),
178
+ lastLogin: new Date().toISOString(),
179
+ name: payload.deviceName,
180
+ owner,
181
+ signKey: payload.signKey,
182
+ };
183
+
184
+ await this.db.insertInto("devices").values(device).execute();
185
+
186
+ const medPreKeys = {
187
+ deviceID: device.deviceID,
188
+ index: payload.preKeyIndex,
189
+ keyID: crypto.randomUUID(),
190
+ publicKey: payload.preKey,
191
+ signature: payload.preKeySignature,
192
+ userID: owner,
193
+ };
194
+
195
+ await this.db.insertInto("preKeys").values(medPreKeys).execute();
196
+
197
+ return toDevice(device);
198
+ }
199
+
200
+ public async createEmoji(emoji: Emoji): Promise<void> {
201
+ await this.db.insertInto("emojis").values(emoji).execute();
202
+ }
203
+
204
+ public async createFile(file: FileSQL): Promise<void> {
205
+ await this.db.insertInto("files").values(file).execute();
206
+ }
207
+
208
+ public async createInvite(
209
+ inviteID: string,
210
+ serverID: string,
211
+ ownerID: string,
212
+ expiration: string,
213
+ ): Promise<Invite> {
214
+ const invite: Invite = {
215
+ expiration,
216
+ inviteID,
217
+ owner: ownerID,
218
+ serverID,
219
+ };
220
+
221
+ await this.db.insertInto("invites").values(invite).execute();
222
+ return invite;
223
+ }
224
+
225
+ public async createPermission(
226
+ userID: string,
227
+ resourceType: string,
228
+ resourceID: string,
229
+ powerLevel: number,
230
+ ): Promise<Permission> {
231
+ const permissionID = crypto.randomUUID();
232
+
233
+ // check if it already exists
234
+ const checkPermission = await this.db
235
+ .selectFrom("permissions")
236
+ .selectAll()
237
+ .where("userID", "=", userID)
238
+ .where("resourceID", "=", resourceID)
239
+ .execute();
240
+ const existing = checkPermission[0];
241
+ if (existing) {
242
+ return existing;
243
+ }
244
+
245
+ const permission: Permission = {
246
+ permissionID,
247
+ powerLevel,
248
+ resourceID,
249
+ resourceType,
250
+ userID,
251
+ };
252
+
253
+ await this.db.insertInto("permissions").values(permission).execute();
254
+ return permission;
255
+ }
256
+
257
+ public async createServer(name: string, ownerID: string): Promise<Server> {
258
+ // create the server
259
+ const server: Server = {
260
+ name,
261
+ serverID: crypto.randomUUID(),
262
+ };
263
+ await this.db
264
+ .insertInto("servers")
265
+ .values({
266
+ icon: server.icon ?? null,
267
+ name: server.name,
268
+ serverID: server.serverID,
269
+ })
270
+ .execute();
271
+ // create the admin permission
272
+ await this.createPermission(ownerID, "server", server.serverID, 100);
273
+ // create the general channel
274
+ await this.createChannel("general", server.serverID);
275
+ return server;
276
+ }
277
+
278
+ public async createUser(
279
+ regKey: Uint8Array,
280
+ regPayload: RegistrationPayload,
281
+ ): Promise<[null | UserRecord, Error | null]> {
282
+ try {
283
+ const salt = xMakeNonce();
284
+ const passwordHash = hashPassword(regPayload.password, salt);
285
+
286
+ const user: UserRecord = {
287
+ lastSeen: new Date().toISOString(),
288
+ passwordHash: passwordHash.toString("hex"),
289
+ passwordSalt: XUtils.encodeHex(salt),
290
+ userID: uuidStringify(regKey),
291
+ username: regPayload.username,
292
+ };
293
+
294
+ await this.db
295
+ .insertInto("users")
296
+ .values({
297
+ ...user,
298
+ lastSeen: user.lastSeen,
299
+ })
300
+ .execute();
301
+ await this.createDevice(user.userID, regPayload);
302
+
303
+ return [user, null];
304
+ } catch (err: unknown) {
305
+ return [null, err instanceof Error ? err : new Error(String(err))];
306
+ }
307
+ }
308
+
309
+ public async deleteChannel(channelID: string): Promise<void> {
310
+ await this.deletePermissions(channelID);
311
+ await this.db
312
+ .deleteFrom("mail")
313
+ .where("group", "=", channelID)
314
+ .execute();
315
+ await this.db
316
+ .deleteFrom("channels")
317
+ .where("channelID", "=", channelID)
318
+ .execute();
319
+ }
320
+
321
+ public async deleteDevice(deviceID: string): Promise<void> {
322
+ await this.db
323
+ .deleteFrom("preKeys")
324
+ .where("deviceID", "=", deviceID)
325
+ .execute();
326
+
327
+ await this.db
328
+ .deleteFrom("oneTimeKeys")
329
+ .where("deviceID", "=", deviceID)
330
+ .execute();
331
+
332
+ await this.db
333
+ .updateTable("devices")
334
+ .set({ deleted: 1 })
335
+ .where("deviceID", "=", deviceID)
336
+ .execute();
337
+ }
338
+
339
+ public async deleteEmoji(emojiID: string): Promise<void> {
340
+ await this.db
341
+ .deleteFrom("emojis")
342
+ .where("emojiID", "=", emojiID)
343
+ .execute();
344
+ }
345
+
346
+ public async deleteInvite(inviteID: string): Promise<void> {
347
+ await this.db
348
+ .deleteFrom("invites")
349
+ .where("inviteID", "=", inviteID)
350
+ .execute();
351
+ }
352
+
353
+ public async deleteMail(nonce: Uint8Array, userID: string): Promise<void> {
354
+ await this.db
355
+ .deleteFrom("mail")
356
+ .where("nonce", "=", XUtils.encodeHex(nonce))
357
+ .where("recipient", "=", userID)
358
+ .execute();
359
+ }
360
+
361
+ public async deletePermission(permissionID: string): Promise<void> {
362
+ await this.db
363
+ .deleteFrom("permissions")
364
+ .where("permissionID", "=", permissionID)
365
+ .execute();
366
+ }
367
+
368
+ public async deletePermissions(resourceID: string): Promise<void> {
369
+ await this.db
370
+ .deleteFrom("permissions")
371
+ .where("resourceID", "=", resourceID)
372
+ .execute();
373
+ }
374
+
375
+ public async deleteServer(serverID: string): Promise<void> {
376
+ await this.deletePermissions(serverID);
377
+ const channels = await this.retrieveChannels(serverID);
378
+ for (const channel of channels) {
379
+ await this.deleteChannel(channel.channelID);
380
+ }
381
+ await this.db
382
+ .deleteFrom("servers")
383
+ .where("serverID", "=", serverID)
384
+ .execute();
385
+ }
386
+
387
+ public async getKeyBundle(deviceID: string): Promise<KeyBundle | null> {
388
+ const device = await this.retrieveDevice(deviceID);
389
+ if (!device) {
390
+ throw new Error("DeviceID not found.");
391
+ }
392
+ const otk = (await this.getOTK(deviceID)) || undefined;
393
+ const preKey = await this.getPreKeys(deviceID);
394
+ if (!preKey) {
395
+ throw new Error("Failed to get prekey.");
396
+ }
397
+ const keyBundle: KeyBundle = {
398
+ otk,
399
+ preKey,
400
+ signKey: XUtils.decodeHex(device.signKey),
401
+ };
402
+ return keyBundle;
403
+ }
404
+
405
+ public async getOTK(deviceID: string): Promise<null | PreKeysWS> {
406
+ const rows: PreKeysSQL[] = await this.db
407
+ .selectFrom("oneTimeKeys")
408
+ .selectAll()
409
+ .where("deviceID", "=", deviceID)
410
+ .orderBy("index")
411
+ .limit(1)
412
+ .execute();
413
+ const otkInfo = rows[0];
414
+ if (!otkInfo) {
415
+ return null;
416
+ }
417
+ const otk: PreKeysWS = {
418
+ deviceID: otkInfo.deviceID,
419
+ index: otkInfo.index,
420
+ publicKey: XUtils.decodeHex(otkInfo.publicKey),
421
+ signature: XUtils.decodeHex(otkInfo.signature),
422
+ };
423
+
424
+ // delete the otk
425
+ await this.db
426
+ .deleteFrom("oneTimeKeys")
427
+ .where("deviceID", "=", deviceID)
428
+ .where("index", "=", otk.index)
429
+ .execute();
430
+ return otk;
431
+ }
432
+
433
+ public async getOTKCount(deviceID: string): Promise<number> {
434
+ const result = await this.db
435
+ .selectFrom("oneTimeKeys")
436
+ .select((eb) => eb.fn.countAll().as("count"))
437
+ .where("deviceID", "=", deviceID)
438
+ .executeTakeFirst();
439
+ return Number(result?.count ?? 0);
440
+ }
441
+
442
+ public async getPreKeys(deviceID: string): Promise<null | PreKeysWS> {
443
+ const rows: PreKeysSQL[] = await this.db
444
+ .selectFrom("preKeys")
445
+ .selectAll()
446
+ .where("deviceID", "=", deviceID)
447
+ .execute();
448
+ const preKeyInfo = rows[0];
449
+ if (!preKeyInfo) {
450
+ return null;
451
+ }
452
+ const preKey: PreKeysWS = {
453
+ deviceID: preKeyInfo.deviceID,
454
+ index: preKeyInfo.index,
455
+ publicKey: XUtils.decodeHex(preKeyInfo.publicKey),
456
+ signature: XUtils.decodeHex(preKeyInfo.signature),
457
+ };
458
+ return preKey;
459
+ }
460
+
461
+ public async getRequestsTotal(): Promise<number> {
462
+ const row = await this.db
463
+ .selectFrom("service_metrics")
464
+ .select("metric_value")
465
+ .where("metric_key", "=", "requests_total")
466
+ .executeTakeFirst();
467
+ const raw = row?.metric_value;
468
+ const count = Number(raw);
469
+ if (!Number.isFinite(count) || count < 0) {
470
+ return 0;
471
+ }
472
+ return count;
473
+ }
474
+
475
+ public async incrementRequestsTotal(by = 1): Promise<void> {
476
+ if (!Number.isFinite(by) || by <= 0) {
477
+ return;
478
+ }
479
+ await this.db
480
+ .updateTable("service_metrics")
481
+ .set({
482
+ metric_value: sql`metric_value + ${Math.floor(by)}`,
483
+ })
484
+ .where("metric_key", "=", "requests_total")
485
+ .execute();
486
+ }
487
+
488
+ public async isHealthy(): Promise<boolean> {
489
+ try {
490
+ await sql`select 1 as ok`.execute(this.db);
491
+ return true;
492
+ } catch (err: unknown) {
493
+ this.log.warn("Database health check failed: " + String(err));
494
+ return false;
495
+ }
496
+ }
497
+
498
+ public async markDeviceLogin(device: Device): Promise<void> {
499
+ await this.db
500
+ .updateTable("devices")
501
+ .set({ lastLogin: new Date().toISOString() })
502
+ .where("deviceID", "=", device.deviceID)
503
+ .execute();
504
+ }
505
+
506
+ public async markUserSeen(user: UserRecord): Promise<void> {
507
+ await this.db
508
+ .updateTable("users")
509
+ .set({ lastSeen: new Date().toISOString() })
510
+ .where("userID", "=", user.userID)
511
+ .execute();
512
+ }
513
+
514
+ /**
515
+ * Retrives a list of users that should be notified when a specific resourceID
516
+ * experiences changes.
517
+ *
518
+ * @param resourceID
519
+ */
520
+ public async retrieveAffectedUsers(
521
+ resourceID: string,
522
+ ): Promise<UserRecord[]> {
523
+ const permissionList =
524
+ await this.retrievePermissionsByResourceID(resourceID);
525
+
526
+ const users: UserRecord[] = [];
527
+ for (const permission of permissionList) {
528
+ const user = await this.retrieveUser(permission.userID);
529
+ if (user) {
530
+ users.push(user);
531
+ }
532
+ }
533
+
534
+ return users;
535
+ }
536
+
537
+ public async retrieveChannel(channelID: string): Promise<Channel | null> {
538
+ const channels: Channel[] = await this.db
539
+ .selectFrom("channels")
540
+ .selectAll()
541
+ .where("channelID", "=", channelID)
542
+ .limit(1)
543
+ .execute();
544
+
545
+ return channels[0] ?? null;
546
+ }
547
+
548
+ public async retrieveChannels(serverID: string): Promise<Channel[]> {
549
+ const channels: Channel[] = await this.db
550
+ .selectFrom("channels")
551
+ .selectAll()
552
+ .where("serverID", "=", serverID)
553
+ .execute();
554
+ return channels;
555
+ }
556
+
557
+ public async retrieveDevice(deviceID: string): Promise<Device | null> {
558
+ if (uuidValidate(deviceID)) {
559
+ const rows = await this.db
560
+ .selectFrom("devices")
561
+ .selectAll()
562
+ .where("deviceID", "=", deviceID)
563
+ .where("deleted", "=", 0)
564
+ .execute();
565
+
566
+ const device = rows[0];
567
+ return device ? toDevice(device) : null;
568
+ }
569
+ if (pubkeyRegex.test(deviceID)) {
570
+ const rows = await this.db
571
+ .selectFrom("devices")
572
+ .selectAll()
573
+ .where("signKey", "=", deviceID)
574
+ .where("deleted", "=", 0)
575
+ .execute();
576
+ const device = rows[0];
577
+ return device ? toDevice(device) : null;
578
+ }
579
+ return null;
580
+ }
581
+
582
+ public async retrieveEmoji(emojiID: string): Promise<Emoji | null> {
583
+ const rows = await this.db
584
+ .selectFrom("emojis")
585
+ .selectAll()
586
+ .where("emojiID", "=", emojiID)
587
+ .execute();
588
+ return rows[0] ?? null;
589
+ }
590
+
591
+ public async retrieveEmojiList(userID: string): Promise<Emoji[]> {
592
+ return this.db
593
+ .selectFrom("emojis")
594
+ .selectAll()
595
+ .where("owner", "=", userID)
596
+ .execute();
597
+ }
598
+
599
+ public async retrieveFile(fileID: string): Promise<FileSQL | null> {
600
+ const file = await this.db
601
+ .selectFrom("files")
602
+ .selectAll()
603
+ .where("fileID", "=", fileID)
604
+ .execute();
605
+ return file[0] ?? null;
606
+ }
607
+
608
+ public async retrieveGroupMembers(
609
+ channelID: string,
610
+ ): Promise<UserRecord[]> {
611
+ const channel = await this.retrieveChannel(channelID);
612
+ if (!channel) {
613
+ return [];
614
+ }
615
+ const permissions: Permission[] = await this.db
616
+ .selectFrom("permissions")
617
+ .selectAll()
618
+ .where("resourceID", "=", channel.serverID)
619
+ .execute();
620
+
621
+ const groupMembers: UserRecord[] = [];
622
+ for (const permission of permissions) {
623
+ const user = await this.retrieveUser(permission.userID);
624
+ if (user) {
625
+ groupMembers.push(user);
626
+ }
627
+ }
628
+
629
+ return groupMembers;
630
+ }
631
+
632
+ public async retrieveInvite(inviteID: string): Promise<Invite | null> {
633
+ const rows = await this.db
634
+ .selectFrom("invites")
635
+ .selectAll()
636
+ .where("inviteID", "=", inviteID)
637
+ .execute();
638
+ return rows[0] ?? null;
639
+ }
640
+
641
+ public async retrieveMail(
642
+ deviceID: string,
643
+ ): Promise<[Uint8Array, MailWS, string][]> {
644
+ const rawRows = await this.db
645
+ .selectFrom("mail")
646
+ .selectAll()
647
+ .where("recipient", "=", deviceID)
648
+ .execute();
649
+ const rows: MailSQL[] = rawRows.map(toMailSQL);
650
+
651
+ const fixMail: (mail: MailSQL) => [Uint8Array, MailWS, string] = (
652
+ mail,
653
+ ) => {
654
+ const msgb: MailWS = {
655
+ authorID: mail.authorID,
656
+ cipher: XUtils.decodeHex(mail.cipher),
657
+ extra: XUtils.decodeHex(mail.extra),
658
+ forward: mail.forward,
659
+ group: mail.group ? XUtils.decodeHex(mail.group) : null,
660
+ mailID: mail.mailID,
661
+ mailType: mail.mailType,
662
+ nonce: XUtils.decodeHex(mail.nonce),
663
+ readerID: mail.readerID,
664
+ recipient: mail.recipient,
665
+ sender: mail.sender,
666
+ };
667
+
668
+ const msgh = XUtils.decodeHex(mail.header);
669
+ return [msgh, msgb, mail.time];
670
+ };
671
+
672
+ const allMail = rows.map(fixMail);
673
+
674
+ return allMail;
675
+ }
676
+
677
+ public async retrievePermission(
678
+ permissionID: string,
679
+ ): Promise<null | Permission> {
680
+ const rows = await this.db
681
+ .selectFrom("permissions")
682
+ .selectAll()
683
+ .where("permissionID", "=", permissionID)
684
+ .execute();
685
+
686
+ return rows[0] ?? null;
687
+ }
688
+
689
+ public async retrievePermissions(
690
+ userID: string,
691
+ resourceType: string,
692
+ ): Promise<Permission[]> {
693
+ if (resourceType === "all") {
694
+ const sList = await this.db
695
+ .selectFrom("permissions")
696
+ .selectAll()
697
+ .where("userID", "=", userID)
698
+ .execute();
699
+ return sList;
700
+ }
701
+ const serverList = await this.db
702
+ .selectFrom("permissions")
703
+ .selectAll()
704
+ .where("userID", "=", userID)
705
+ .where("resourceType", "=", resourceType)
706
+ .execute();
707
+ return serverList;
708
+ }
709
+
710
+ public async retrievePermissionsByResourceID(
711
+ resourceID: string,
712
+ ): Promise<Permission[]> {
713
+ return this.db
714
+ .selectFrom("permissions")
715
+ .selectAll()
716
+ .where("resourceID", "=", resourceID)
717
+ .execute();
718
+ }
719
+
720
+ public async retrieveServer(serverID: string): Promise<null | Server> {
721
+ const rows = await this.db
722
+ .selectFrom("servers")
723
+ .selectAll()
724
+ .where("serverID", "=", serverID)
725
+ .limit(1)
726
+ .execute();
727
+ const row = rows[0];
728
+ return row ? toServer(row) : null;
729
+ }
730
+
731
+ public async retrieveServerInvites(serverID: string): Promise<Invite[]> {
732
+ const rows = await this.db
733
+ .selectFrom("invites")
734
+ .selectAll()
735
+ .where("serverID", "=", serverID)
736
+ .execute();
737
+
738
+ return rows.filter((invite: Invite) => {
739
+ const valid =
740
+ new Date(Date.now()).getTime() <
741
+ new Date(invite.expiration).getTime();
742
+
743
+ if (!valid) {
744
+ void this.deleteInvite(invite.inviteID);
745
+ }
746
+
747
+ return valid;
748
+ });
749
+ }
750
+
751
+ public async retrieveServers(userID: string): Promise<Server[]> {
752
+ const serverPerms = await this.retrievePermissions(userID, "server");
753
+ const serverList: Server[] = [];
754
+ for (const perm of serverPerms) {
755
+ const server = await this.retrieveServer(perm.resourceID);
756
+ if (server) {
757
+ serverList.push(server);
758
+ }
759
+ }
760
+ return serverList;
761
+ }
762
+
763
+ // the identifier can be username, public key, or userID
764
+ public async retrieveUser(
765
+ userIdentifier: string,
766
+ ): Promise<null | UserRecord> {
767
+ let rows;
768
+ if (uuidValidate(userIdentifier)) {
769
+ rows = await this.db
770
+ .selectFrom("users")
771
+ .selectAll()
772
+ .where("userID", "=", userIdentifier)
773
+ .limit(1)
774
+ .execute();
775
+ } else {
776
+ rows = await this.db
777
+ .selectFrom("users")
778
+ .selectAll()
779
+ .where("username", "=", userIdentifier)
780
+ .limit(1)
781
+ .execute();
782
+ }
783
+
784
+ const row = rows[0];
785
+ return row ? toUserRecord(row) : null;
786
+ }
787
+
788
+ public async retrieveUserDeviceList(userIDs: string[]): Promise<Device[]> {
789
+ const rows = await this.db
790
+ .selectFrom("devices")
791
+ .selectAll()
792
+ .where("owner", "in", userIDs)
793
+ .where("deleted", "=", 0)
794
+ .execute();
795
+ return rows.map(toDevice);
796
+ }
797
+
798
+ public async retrieveUsers(): Promise<UserRecord[]> {
799
+ const rows = await this.db.selectFrom("users").selectAll().execute();
800
+ return rows.map(toUserRecord);
801
+ }
802
+
803
+ public async saveMail(
804
+ mail: MailWS,
805
+ header: Uint8Array,
806
+ deviceID: string,
807
+ userID: string,
808
+ ): Promise<void> {
809
+ const entry: MailSQL = {
810
+ authorID: userID,
811
+ cipher: XUtils.encodeHex(mail.cipher),
812
+ extra: XUtils.encodeHex(mail.extra),
813
+ forward: mail.forward,
814
+ group: mail.group ? XUtils.encodeHex(mail.group) : null,
815
+ header: XUtils.encodeHex(header),
816
+ mailID: mail.mailID,
817
+ mailType: mail.mailType,
818
+ nonce: XUtils.encodeHex(mail.nonce),
819
+ readerID: mail.readerID,
820
+ recipient: mail.recipient,
821
+ sender: deviceID,
822
+ time: new Date().toISOString(),
823
+ };
824
+
825
+ await this.db
826
+ .insertInto("mail")
827
+ .values({
828
+ ...entry,
829
+ forward: entry.forward ? 1 : 0,
830
+ time: entry.time,
831
+ })
832
+ .execute();
833
+ }
834
+
835
+ public async saveOTK(
836
+ userID: string,
837
+ deviceID: string,
838
+ otks: PreKeysWS[],
839
+ ): Promise<void> {
840
+ for (const otk of otks) {
841
+ const newOTK = {
842
+ deviceID: otk.deviceID,
843
+ index: otk.index ?? 0,
844
+ keyID: crypto.randomUUID(),
845
+ publicKey: XUtils.encodeHex(otk.publicKey),
846
+ signature: XUtils.encodeHex(otk.signature),
847
+ userID,
848
+ };
849
+ await this.db.insertInto("oneTimeKeys").values(newOTK).execute();
850
+ }
851
+ }
852
+
853
+ private async init(): Promise<void> {
854
+ const migrator = new Migrator({
855
+ db: this.db,
856
+ provider: new CrossPlatformMigrationProvider(migrationFolder),
857
+ });
858
+ const { error } = await migrator.migrateToLatest();
859
+ if (error) {
860
+ this.emit("error", error);
861
+ return;
862
+ }
863
+ this.emit("ready");
864
+ }
865
+ }
866
+
867
+ function toDevice(row: {
868
+ deleted: number;
869
+ deviceID: string;
870
+ lastLogin: string;
871
+ name: string;
872
+ owner: string;
873
+ signKey: string;
874
+ }): Device {
875
+ return { ...row, deleted: Boolean(row.deleted) };
876
+ }
877
+
878
+ function toMailSQL(row: {
879
+ authorID: string;
880
+ cipher: string;
881
+ extra: null | string;
882
+ forward: number;
883
+ group: null | string;
884
+ header: string;
885
+ mailID: string;
886
+ mailType: number;
887
+ nonce: string;
888
+ readerID: string;
889
+ recipient: string;
890
+ sender: string;
891
+ time: string;
892
+ }): MailSQL {
893
+ return {
894
+ ...row,
895
+ extra: row.extra ?? "",
896
+ forward: Boolean(row.forward),
897
+ mailType: parseMailType(row.mailType),
898
+ time: row.time,
899
+ };
900
+ }
901
+
902
+ function toServer(row: {
903
+ icon: null | string;
904
+ name: string;
905
+ serverID: string;
906
+ }): Server {
907
+ return {
908
+ icon: row.icon ?? undefined,
909
+ name: row.name,
910
+ serverID: row.serverID,
911
+ };
912
+ }
913
+
914
+ function toUserRecord(row: {
915
+ lastSeen: string;
916
+ passwordHash: string;
917
+ passwordSalt: string;
918
+ userID: string;
919
+ username: string;
920
+ }): UserRecord {
921
+ return { ...row };
922
+ }
923
+
924
+ export const hashPassword = (password: string, salt: Uint8Array) =>
925
+ pbkdf2Sync(password, salt, ITERATIONS, 32, "sha512");