@vex-chat/spire 0.8.0 → 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 (370) hide show
  1. package/README.md +82 -26
  2. package/dist/ClientManager.d.ts +23 -25
  3. package/dist/ClientManager.js +230 -249
  4. package/dist/ClientManager.js.map +1 -0
  5. package/dist/Database.d.ts +49 -47
  6. package/dist/Database.js +698 -773
  7. package/dist/Database.js.map +1 -0
  8. package/dist/Spire.d.ts +22 -14
  9. package/dist/Spire.js +496 -236
  10. package/dist/Spire.js.map +1 -0
  11. package/dist/__tests__/Database.spec.js +116 -75
  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 +64 -64
  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 +81 -62
  35. package/dist/server/file.js.map +1 -0
  36. package/dist/server/index.d.ts +8 -10
  37. package/dist/server/index.js +414 -405
  38. package/dist/server/index.js.map +1 -0
  39. package/dist/server/invite.d.ts +4 -5
  40. package/dist/server/invite.js +18 -52
  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 +55 -66
  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 -65
  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/052242d0-4129-4a6e-8076-22709c157549 +0 -0
  100. package/avatars/0a677c9a-4986-4b2c-ae2e-12faf22f55db +0 -0
  101. package/avatars/0ba21b91-decb-4a3e-ac86-4dd54d805a9a +0 -0
  102. package/avatars/0c48d8b6-1d1b-4297-a6c6-2fe50af6fc35 +0 -0
  103. package/avatars/0d993cdf-19a6-4299-a4ee-a06579d106cf +0 -0
  104. package/avatars/17b000e4-ac38-46ec-9dec-d2568086129a +0 -0
  105. package/avatars/19dc5594-0f06-4ac6-af18-8740dd39ef6b +0 -0
  106. package/avatars/20444fa3-6d5e-429e-b55f-b81c3d2c61ee +0 -0
  107. package/avatars/21c0512a-5630-4931-9442-d66db66737be +0 -0
  108. package/avatars/22830a60-0b6f-4912-83a5-72245465f332 +0 -0
  109. package/avatars/243639ce-f59f-4404-a1f1-4ec0eb5d2af3 +0 -0
  110. package/avatars/30d2c01d-7b7f-4ea9-9859-1c90837a23f7 +0 -0
  111. package/avatars/315a04f0-9a6f-4b0f-bb9f-5fa774c4752b +0 -0
  112. package/avatars/3563d333-53fe-4885-ac2d-9a4f761db85e +0 -0
  113. package/avatars/36a10c00-3b4c-437f-8e1f-4428ecde0003 +0 -0
  114. package/avatars/40b83eeb-c6e8-4268-82ab-69799a796405 +0 -0
  115. package/avatars/45b5ddb9-ad2c-4404-8ab3-cb4699e6d61c +0 -0
  116. package/avatars/4e4f0ffb-9a75-479a-bccb-446d0bf85020 +0 -0
  117. package/avatars/4e62c3bd-08c6-4fdd-bd65-f01c7322ed64 +0 -0
  118. package/avatars/5004d2e7-51af-44af-8776-6c71f7019843 +0 -0
  119. package/avatars/5041eb29-5c4b-4dea-8c1b-31ba4473161a +0 -0
  120. package/avatars/5065cf78-31c5-46cb-8d5c-c0b6be2d994e +0 -0
  121. package/avatars/51b91d2c-8956-4d73-b4ad-ca6a8d9da9a8 +0 -0
  122. package/avatars/58264a2c-5651-4a42-8ca2-a9907b311e48 +0 -0
  123. package/avatars/58c2357c-8080-4725-a0ce-182c96b037c4 +0 -0
  124. package/avatars/59b5f6dd-8e04-4d15-b4dc-c1c652558a74 +0 -0
  125. package/avatars/5b417a78-b274-48bc-98a4-6e54b74ee62d +0 -0
  126. package/avatars/611f5a93-1ed4-45a1-bc8e-e8e413f9b171 +0 -0
  127. package/avatars/65abe919-9921-46f6-9bc9-183e9cc53c8a +0 -0
  128. package/avatars/6934202d-1546-4270-8a15-97ba8b8c6fa9 +0 -0
  129. package/avatars/6acd24be-f4b7-4399-9e7c-807821828d29 +0 -0
  130. package/avatars/6b2d6ac5-e35c-4297-994e-f0eb6fe56740 +0 -0
  131. package/avatars/6cae9ddd-f163-44e7-a632-30425716a159 +0 -0
  132. package/avatars/6d90e79e-b9c1-4b89-843b-96636de8d26b +0 -0
  133. package/avatars/6f82c7bb-a974-4372-8a64-ce287e668c8c +0 -0
  134. package/avatars/74a45091-5a76-4bb7-ae8a-cd7373adc128 +0 -0
  135. package/avatars/7d071ab2-e0b5-4dbb-8bf5-1fae50c3663f +0 -0
  136. package/avatars/7de818c8-bda1-4f51-976e-160fc087184b +0 -0
  137. package/avatars/873937df-8cb1-427a-92bf-f829f4259624 +0 -0
  138. package/avatars/8b45ffa5-3322-4109-bfa0-1be088336135 +0 -0
  139. package/avatars/8efaa426-22a9-42a2-b4ac-a275717b812f +0 -0
  140. package/avatars/903fd1a6-d6ea-431c-b98f-f21e424a2852 +0 -0
  141. package/avatars/943d8533-5174-4199-990f-1ec69e5d60c4 +0 -0
  142. package/avatars/952d014e-3804-4cd2-a4a0-ffe40a11e4ac +0 -0
  143. package/avatars/95d3acb0-724d-4413-b20f-edad55812d5d +0 -0
  144. package/avatars/9641a946-f613-471b-bedd-c1730b96b51e +0 -0
  145. package/avatars/9b01cbbf-f6b2-43fb-b569-589b6f2a8134 +0 -0
  146. package/avatars/9cd4424d-a34f-4467-acc0-93cf82703e0d +0 -0
  147. package/avatars/9d9ad3b0-e5a6-420a-a6c5-fb9085b70376 +0 -0
  148. package/avatars/9e9e34b5-4e63-4c4b-9722-c7f5674b47aa +0 -0
  149. package/avatars/a387d5c1-59eb-4a6b-80c1-a8982ed12c33 +0 -0
  150. package/avatars/a3e86d21-d881-4824-8ebf-45e3bf0f9186 +0 -0
  151. package/avatars/a8d5cc1c-3f42-4b7b-8d33-f9a9ef77f96b +0 -0
  152. package/avatars/a91d815f-badc-4604-a7be-6c7a44e6101d +0 -0
  153. package/avatars/aa8d0324-bcec-4737-a8c4-bdbff914148d +0 -0
  154. package/avatars/abb8a941-8b6b-47d7-a2f9-8b153ba44aa2 +0 -0
  155. package/avatars/b011bb38-1ef3-4d22-82fa-8bf60faf7b5d +0 -0
  156. package/avatars/b24bcbc1-11f0-473d-a8b9-ba8ce4ca127d +0 -0
  157. package/avatars/b2607346-af1c-4e98-b725-7650a766db2a +0 -0
  158. package/avatars/b6300f7c-cb37-459b-b1bf-8a0a0e797a52 +0 -0
  159. package/avatars/b7d3cff3-84dd-4547-93a1-de1aaa8aa34c +0 -0
  160. package/avatars/baa4b51c-e97f-4f51-bcb3-f27bc506cfaf +0 -0
  161. package/avatars/be7022e4-e292-4515-80d5-f9b61ebeb4ce +0 -0
  162. package/avatars/bed596a3-7569-4854-9e76-f52d33c0a541 +0 -0
  163. package/avatars/bf69992f-3f72-4930-99bb-0ffe17f3aebf +0 -0
  164. package/avatars/ca00c250-c6d4-464d-a6de-1c8467a18fe8 +0 -0
  165. package/avatars/ca19d78f-c0bc-4bd5-b26f-6923cb19996d +0 -0
  166. package/avatars/cda4d6e1-e0a4-4024-ac95-6de98e713b98 +0 -0
  167. package/avatars/cf72c30d-2da8-4e81-aa71-735b9e714274 +0 -0
  168. package/avatars/d5a35b78-99b3-4564-b6b9-b2ccab28c470 +0 -0
  169. package/avatars/dadb38c1-2a9d-47a3-8d92-b56b6166973c +0 -0
  170. package/avatars/e68705c7-375d-4423-9a86-29a16bd3ee0e +0 -0
  171. package/avatars/e9af3e4c-1f62-4302-8b99-b68ce93b7a86 +0 -0
  172. package/avatars/ea7e7331-e845-4189-8248-5f5b1d63f5e3 +0 -0
  173. package/avatars/ef4f8dcb-ef6c-4e7a-9be1-0476161bfce5 +0 -0
  174. package/avatars/ef7d0917-a206-4f88-8b60-93f8253774dc +0 -0
  175. package/avatars/f1a554d6-1db3-4ff5-b0dc-b607d6c3b4ff +0 -0
  176. package/avatars/f1fecc21-c81f-49a8-88f3-f942a0a679f6 +0 -0
  177. package/avatars/f30a2427-1755-4053-813e-129a179e1dd3 +0 -0
  178. package/avatars/f5370717-5109-46a5-a8d7-e1dd996d0615 +0 -0
  179. package/avatars/f6dd7126-1144-4998-bbd3-d4e0fbee2e95 +0 -0
  180. package/avatars/f83413d5-0003-4756-9ece-745fd61cc468 +0 -0
  181. package/avatars/f9b3149e-7ec8-4bb3-a9b9-dcbe66dac197 +0 -0
  182. package/avatars/fa41d70b-857e-4423-bd7d-26ddcddc13b9 +0 -0
  183. package/avatars/fb551ee8-99c7-400b-8e1d-322ce4619998 +0 -0
  184. package/avatars/fe83a6d4-abb0-4ab0-b61d-76a7cc08be84 +0 -0
  185. package/dist/migrations/20210103192527_users.d.ts +0 -3
  186. package/dist/migrations/20210103192527_users.js +0 -30
  187. package/dist/migrations/20210103193502_mail.d.ts +0 -3
  188. package/dist/migrations/20210103193502_mail.js +0 -35
  189. package/dist/migrations/20210103193525_preKeys.d.ts +0 -3
  190. package/dist/migrations/20210103193525_preKeys.js +0 -30
  191. package/dist/migrations/20210103193553_oneTimeKeys.d.ts +0 -3
  192. package/dist/migrations/20210103193553_oneTimeKeys.js +0 -30
  193. package/dist/migrations/20210103193615_servers.d.ts +0 -3
  194. package/dist/migrations/20210103193615_servers.js +0 -28
  195. package/dist/migrations/20210103193729_channels.d.ts +0 -3
  196. package/dist/migrations/20210103193729_channels.js +0 -28
  197. package/dist/migrations/20210103193749_permissions.d.ts +0 -3
  198. package/dist/migrations/20210103193749_permissions.js +0 -30
  199. package/dist/migrations/20210103193801_files.d.ts +0 -3
  200. package/dist/migrations/20210103193801_files.js +0 -28
  201. package/emoji/04d98632-2c86-421b-a407-17f14fe86f8f +0 -0
  202. package/emoji/1160ed6e-1163-4043-9808-4029e863ed30 +0 -0
  203. package/emoji/1547ab18-1635-4a80-a82d-ebbb767b9932 +0 -0
  204. package/emoji/16922521-f6cb-4de4-860c-27916b22c6ba +0 -0
  205. package/emoji/198a9432-0e41-4866-994a-448d4775afcb +0 -0
  206. package/emoji/1be886b3-c9c5-4593-b516-f357ed931f96 +0 -0
  207. package/emoji/1c2b3d1d-637f-4103-b066-4bc4511a3ad7 +0 -0
  208. package/emoji/1efd27e7-b15f-475c-8b32-9159d26b169d +0 -0
  209. package/emoji/270b9409-0ea5-4be2-a239-a8dce13f9c31 +0 -0
  210. package/emoji/27812f76-fee2-49dd-a217-363de6d159dc +0 -0
  211. package/emoji/297ec202-8c24-44c6-aead-689d6d461883 +0 -0
  212. package/emoji/2bf06d86-17cb-4f40-a5ef-bd75d239a1a3 +0 -0
  213. package/emoji/31a75163-1cce-4dc1-b0a2-ecad6a4c500b +0 -0
  214. package/emoji/35235635-fdbd-4273-8428-f3cb3e1e8fd3 +0 -0
  215. package/emoji/3690fff2-6824-4403-a6e3-16a6a54979a9 +0 -0
  216. package/emoji/391014c2-59e0-46a8-85ec-7a7fdaca1d2d +0 -0
  217. package/emoji/3b383dcb-6e76-4e85-8e16-7c68040c06c2 +0 -0
  218. package/emoji/42d617a7-b104-42f5-9618-473181f752cf +0 -0
  219. package/emoji/482495d3-cce9-4f88-bf2a-f6003f03a9b5 +0 -0
  220. package/emoji/48390e06-0efb-404c-89bd-5f2be241bd50 +0 -0
  221. package/emoji/4b808d8d-3248-4149-b919-71b108391bcf +0 -0
  222. package/emoji/4bc13544-d82a-4e32-bd17-a70592274314 +0 -0
  223. package/emoji/4fcebf70-8623-4343-8243-67c8547b2edd +0 -0
  224. package/emoji/509d09aa-1214-459c-8081-50918a17b9af +0 -0
  225. package/emoji/5272abd8-d4d7-4b90-acd2-bf30e6c27243 +0 -0
  226. package/emoji/53c272ce-48bd-4d7e-bfb8-a6482b88be54 +0 -0
  227. package/emoji/5b279e65-06f7-4b26-8b4c-d1b48fba728d +0 -0
  228. package/emoji/5bd141f9-4394-4108-9376-66ebbc2c2bc1 +0 -0
  229. package/emoji/5c769156-f9cb-40bd-ab89-4edeece613fd +0 -0
  230. package/emoji/5c85fba9-8ba7-4fc9-b1b2-48dc30d24a1b +0 -0
  231. package/emoji/61a5e565-d20b-40ba-a139-b0c73a6027f3 +0 -0
  232. package/emoji/6913f43d-dd45-456c-9641-a126104d9ae5 +0 -0
  233. package/emoji/6957e74e-9622-492d-a950-242db3752260 +0 -0
  234. package/emoji/6a14bab5-26af-4bfa-9c17-be7c2511976d +0 -0
  235. package/emoji/6be09439-509e-4095-a30a-b1c7c573895d +0 -0
  236. package/emoji/6cc435b1-fe53-433c-b5a9-2b2019053997 +0 -0
  237. package/emoji/74f1b2af-bc7e-4a0f-802a-64ded185d5e2 +0 -0
  238. package/emoji/7890ba09-f02f-428e-807d-006d03d51d4a +0 -0
  239. package/emoji/7dc69179-6b3c-4f40-b20b-0ff573deea2d +0 -0
  240. package/emoji/7dd1b6b1-439d-4279-916a-995408863172 +0 -0
  241. package/emoji/820498ad-a2c8-43a2-ab83-d26f9c2246d4 +0 -0
  242. package/emoji/8319469c-2787-44e5-91a6-c8c39810dd7c +0 -0
  243. package/emoji/86745d1d-9e59-4607-b2b0-46c741079be1 +0 -0
  244. package/emoji/887b3cff-ae9e-4b5f-ad00-3ca9fc72f689 +0 -0
  245. package/emoji/8c6cf621-71d6-4fca-abe6-e19f4dd7f883 +0 -0
  246. package/emoji/8ca6d32e-a1ef-4956-a416-d8d0d680f085 +0 -0
  247. package/emoji/8d979f5e-38a2-4dd1-b3e4-80938bbe499b +0 -0
  248. package/emoji/99f68ea0-e3fe-4f03-9cc3-5f7f5315404d +0 -0
  249. package/emoji/9ad3aedc-7f79-4d68-a144-82e5b5dc3033 +0 -0
  250. package/emoji/9e418c2f-1f0f-46c4-be39-3bda38a28545 +0 -0
  251. package/emoji/a1f616bf-7402-4e24-9111-18acaebabb48 +0 -0
  252. package/emoji/a25ed9c1-3f9c-4e5f-ade2-7b159fb9fbf4 +0 -0
  253. package/emoji/a5176bc2-39a8-467b-8c75-6fbbc81b59c7 +0 -0
  254. package/emoji/a584215c-6547-438b-8ae8-dd490b51890e +0 -0
  255. package/emoji/a739895f-cf61-4b7c-b350-8e8283aaf751 +0 -0
  256. package/emoji/aaa10dd2-02a2-499e-9e17-c83787436508 +0 -0
  257. package/emoji/ae90baf2-a0ef-4d4d-9cc4-94f8ddd60f45 +0 -0
  258. package/emoji/b0564c48-feae-431a-95f9-df597c6c124c +0 -0
  259. package/emoji/b218bb93-e69c-4793-a669-83316650c4e7 +0 -0
  260. package/emoji/b2998c27-85d5-4598-ab41-469aa8e0fcad +0 -0
  261. package/emoji/b3da08ba-4179-4b5d-826f-5fc15e1a3ad2 +0 -0
  262. package/emoji/b840eb6a-a917-4bb2-854b-8f1022e7904b +0 -0
  263. package/emoji/b84baa76-b4b0-4b83-bc83-78661cb4f1d4 +0 -0
  264. package/emoji/baf69d80-8b1d-4032-855f-605cf0d489c3 +0 -0
  265. package/emoji/bb4d372c-ccd0-4a47-b157-b6a3b9f763e2 +0 -0
  266. package/emoji/bdbd1627-c81d-42d9-b3f5-8979e2ab74dc +0 -0
  267. package/emoji/c257388f-8b85-450b-b168-ebdf8d8c3026 +0 -0
  268. package/emoji/c573fde1-faa9-4c1d-a172-e283645afcfd +0 -0
  269. package/emoji/c8e27810-e8ea-47ce-b7ec-cef0a6becb28 +0 -0
  270. package/emoji/cdeef182-b220-4850-9ecf-5d7c472fd754 +0 -0
  271. package/emoji/d59c1aa9-6f81-4c07-96dd-9953401ff211 +0 -0
  272. package/emoji/dd407dbf-a077-40ba-957f-337b3c5efdc7 +0 -0
  273. package/emoji/e01f6e06-5728-4e2c-90fb-314a5827b766 +0 -0
  274. package/emoji/e1f9ed12-a2ce-433d-b454-b833438a1f9c +0 -0
  275. package/emoji/e697e5c4-acd6-41cd-a43e-edee8da3ab7b +0 -0
  276. package/emoji/e8182220-3464-4e31-8c08-466baead7bfc +0 -0
  277. package/emoji/eb0f3fd5-abc9-4abf-b816-d8458aeb7ec8 +0 -0
  278. package/emoji/eb38ecf7-0d13-4c51-b96a-3777f79321c4 +0 -0
  279. package/emoji/ee515c85-b7ce-4493-a427-994cc0af0d59 +0 -0
  280. package/emoji/f485cef2-d3fa-4d59-88af-b79a3105cacf +0 -0
  281. package/emoji/ff0dab2a-7015-4e8c-b0d0-3569058359dc +0 -0
  282. package/files/01087968-07b6-4fdb-9aeb-fa9dc061be94 +0 -0
  283. package/files/030455b3-17cc-415f-b3b3-2bb56c92ee8b +0 -0
  284. package/files/06129f52-a858-4031-ad85-4d7f2bb793af +0 -0
  285. package/files/0a6155a9-069f-45e9-8a06-56992fe55187 +0 -0
  286. package/files/12b9dda5-feb1-4f20-a987-ad422db5ba73 +0 -0
  287. package/files/13c8fa1e-8821-4628-b607-9e4fa4510df7 +0 -0
  288. package/files/159b0ad3-1a30-419d-af22-28a1096ce825 +0 -0
  289. package/files/1699c563-6769-431b-8041-99a6a4386f25 +0 -0
  290. package/files/176b916c-0dd9-4b93-bfdc-b8ccabf15a96 +0 -0
  291. package/files/1a27dad9-8cc9-4a0c-9d4e-7bde63adda60 +0 -0
  292. package/files/1d29832c-059a-4190-bca6-b83ac77540d9 +0 -0
  293. package/files/1dcde013-8833-4369-8726-81236e4eb30e +0 -0
  294. package/files/2080fc9f-d2c4-4fbc-af84-232fe4900a4f +0 -0
  295. package/files/20889623-6869-46a2-999b-c07708c12521 +0 -0
  296. package/files/2107e243-c378-418d-a183-7df13873c65b +0 -0
  297. package/files/225ed61e-8f8c-4d17-b675-9a9f9918d5b4 +0 -0
  298. package/files/29ffba15-5acc-4ef8-b5a4-5ce61d3f6e85 +0 -0
  299. package/files/2a69434c-1d8a-4e9d-90d4-569aaeaae7e3 +0 -0
  300. package/files/2aec8fcf-25bf-478e-b2f6-fe67ad753071 +0 -0
  301. package/files/2c64490e-c7de-4cb2-b22e-70e2ac69d88f +0 -0
  302. package/files/2d80b4f4-6389-4f5d-8d32-f0ed93820907 +0 -0
  303. package/files/2f9dd26e-363f-4445-8ee5-28548007a33a +0 -0
  304. package/files/360dd8f5-76c4-4e86-ba22-9025dc7ca2a4 +0 -0
  305. package/files/3a0a7f5b-45a5-4340-ba7c-6a0fa2c63871 +0 -0
  306. package/files/3a8fa6be-8acf-4b7b-b653-25edc6b28cdc +0 -5
  307. package/files/3d9e191b-2c15-42aa-9928-c2cdbb5e14ca +0 -0
  308. package/files/3dd0f2ef-0d4e-4837-bffc-22aa645cbe85 +0 -0
  309. package/files/402c4b9b-adbf-4ab6-a21d-c17369b48abf +0 -0
  310. package/files/4685d988-33eb-4902-872f-3b824f497c8b +0 -0
  311. package/files/495f6c55-07f8-4713-a444-a2261a789b94 +0 -0
  312. package/files/4acba8e3-567b-4062-b81b-340e205a01de +0 -0
  313. package/files/4c0a10cc-395b-474a-8140-677ed607da89 +0 -0
  314. package/files/537584d9-25ad-4830-808a-a1e3d63e2a52 +0 -0
  315. package/files/5519f10d-745e-4474-8ca8-6c111693704c +0 -0
  316. package/files/5563cf92-a5e3-4be3-867d-9647a02298d4 +0 -0
  317. package/files/55b70d9b-fd58-4800-832c-d6b4521ba6d0 +0 -0
  318. package/files/5623cff3-ce9b-403b-9915-50d2bcbc981c +0 -0
  319. package/files/576314ba-2a1a-4753-8d77-2a9e04f509d1 +0 -0
  320. package/files/58ed97ae-0eac-4e04-add1-76646effa2d5 +0 -0
  321. package/files/68638efd-5389-481d-a841-36164c62c078 +0 -0
  322. package/files/6936d34b-a1f8-4a9d-b005-5544dbdcf5e5 +0 -0
  323. package/files/6bda9e88-3a28-47f7-994e-900ede6bf984 +0 -0
  324. package/files/7024a3b9-a863-4618-9dbd-fa6502017ae0 +0 -0
  325. package/files/707caccb-3780-456d-9056-c20bfdfc0e5b +0 -0
  326. package/files/74b8c73c-032b-4640-a5b3-d30dd270cbcb +0 -0
  327. package/files/767cb262-2974-40f8-8182-f7770b431923 +0 -0
  328. package/files/7a669935-cb48-4349-b26b-7705f8a04fbc +0 -0
  329. package/files/7bc8f678-6ee7-454c-a1f8-bb9358b89c95 +0 -0
  330. package/files/7ccc2699-07ec-4177-a9ce-ee7dc952fda1 +0 -0
  331. package/files/7e0eabf4-b334-4683-8156-ab8d949a0532 +0 -0
  332. package/files/7f0644ab-02a3-4121-adfc-29a7c55cf804 +0 -0
  333. package/files/7ff3266d-f103-48ce-90dd-95b9dfe5fcc9 +0 -0
  334. package/files/836e2e8e-aefd-4b4a-a9b9-bf7436158a8c +0 -0
  335. package/files/875f7ae5-fa23-4fc5-b04a-8433a7f7089c +0 -0
  336. package/files/8ca62da9-f204-49e3-b418-9451661b2904 +0 -0
  337. package/files/9283054c-107f-474d-b61e-f1d0061bcb86 +0 -0
  338. package/files/93b1ce7f-9566-452b-b4be-30f87d3de150 +0 -0
  339. package/files/93fb51c7-e19b-4ac1-9dc3-aeb8da0672ed +0 -0
  340. package/files/9b54a3a1-534a-4ed5-b016-3c74ed4c9edd +0 -0
  341. package/files/9b5beb6f-712d-4969-b127-fd66c9b2a9c6 +0 -0
  342. package/files/9e964fbf-d063-498e-b2e3-79f8d6afcf5f +0 -0
  343. package/files/a66b7a9f-58c2-47a8-a429-a6f0647c6fe9 +0 -0
  344. package/files/a7cbda7d-81ba-40f7-a997-51146af63e5f +0 -0
  345. package/files/ac01e83a-e572-41b6-81ab-c992cff7c170 +0 -0
  346. package/files/ad11a58f-f963-4233-bd29-1658b6b7e600 +0 -0
  347. package/files/ae60a4a8-08b9-4521-a0a3-d015a8b3ed08 +0 -0
  348. package/files/b1d3cf27-8d76-4cf9-aa51-d7c6bfd1b3bf +0 -0
  349. package/files/b2c68863-8554-4ac6-8e99-821f0267cf91 +0 -0
  350. package/files/b3043e01-a771-44af-bf19-5327646ff929 +0 -0
  351. package/files/b6d01b89-def5-4c7c-8e97-a3d88617f8f4 +0 -0
  352. package/files/b8760b32-bb1e-4cd7-a9d6-29c6e0b071bc +0 -0
  353. package/files/ba5e0470-44f7-47b3-bcb5-eeab3ca8c292 +0 -0
  354. package/files/c3969b5f-43ae-43f3-9bdd-d3959c79ca01 +0 -0
  355. package/files/caecd488-dbe6-4a30-a400-bced2ba8dae6 +0 -0
  356. package/files/d7d865b8-3a05-4ed1-b95d-b93dc1ebb9a9 +0 -0
  357. package/files/dbca5f31-cf38-4c1e-83b0-5ec8473196fc +0 -0
  358. package/files/dbf07e82-fff6-4985-bebe-d62c0458bfd0 +0 -0
  359. package/files/dd759c20-eead-4e57-9e74-4d3a2b978e91 +0 -5
  360. package/files/de0b2cf1-981b-4e4a-a04e-ac185d1620cd +0 -0
  361. package/files/de6837fe-5aa2-4ff9-a067-2646a008c780 +0 -0
  362. package/files/e2fde852-91cb-4e01-88a2-ee086b5f227c +0 -0
  363. package/files/e391d1ce-8d39-460c-a462-791730131f7f +0 -0
  364. package/files/e6d9b60f-2b1b-4c6f-ba6d-02f6de90d40f +0 -0
  365. package/files/f693c62d-2ac8-49fd-aa38-30904e013e3c +0 -0
  366. package/files/f749fdf5-193e-41f7-b643-5696f67c6402 +0 -0
  367. package/files/f8910384-e75c-4d65-825f-52a6748f6475 +0 -0
  368. package/files/fad8826b-e952-4acb-a509-3e6543b94d61 +0 -0
  369. package/jest.config.js +0 -13
  370. package/spire.sqlite +0 -0
package/src/Spire.ts ADDED
@@ -0,0 +1,878 @@
1
+ import type { ActionToken, BaseMsg, NotifyMsg, User } from "@vex-chat/types";
2
+ import type { Server } from "http";
3
+ import type winston from "winston";
4
+
5
+ import { EventEmitter } from "events";
6
+ import { execSync } from "node:child_process";
7
+ import * as fs from "node:fs";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ import express from "express";
12
+
13
+ import { XUtils } from "@vex-chat/crypto";
14
+ import {
15
+ type KeyPair,
16
+ xRandomBytes,
17
+ xSignKeyPairFromSecret,
18
+ xSignOpen,
19
+ } from "@vex-chat/crypto";
20
+ import {
21
+ MailWSSchema,
22
+ RegistrationPayloadSchema,
23
+ TokenScopes,
24
+ UserSchema,
25
+ } from "@vex-chat/types";
26
+
27
+ import jwt from "jsonwebtoken";
28
+ import { stringify as uuidStringify } from "uuid";
29
+ import { WebSocketServer } from "ws";
30
+ import { z } from "zod/v4";
31
+
32
+ import { ClientManager } from "./ClientManager.ts";
33
+ import { Database, hashPassword } from "./Database.ts";
34
+ import { initApp, protect } from "./server/index.ts";
35
+ import { authLimiter } from "./server/rateLimit.ts";
36
+ import { censorUser, getParam, getUser } from "./server/utils.ts";
37
+ import { createLogger } from "./utils/createLogger.ts";
38
+ import { getJwtSecret } from "./utils/jwtSecret.ts";
39
+ import { msgpack } from "./utils/msgpack.ts";
40
+
41
+ // expiry of regkeys = 24hr
42
+ export const TOKEN_EXPIRY = 1000 * 60 * 10;
43
+ export const JWT_EXPIRY = "7d";
44
+ export const DEVICE_AUTH_JWT_EXPIRY = "1h";
45
+ const DEVICE_CHALLENGE_EXPIRY = 1000 * 60; // 60 seconds
46
+ const STATUS_LATENCY_BUDGET_MS = 250;
47
+
48
+ // 3-19 chars long
49
+ const usernameRegex = /^(\w{3,19})$/;
50
+
51
+ // ── Zod schemas for trust-boundary validation ──────────────────────────
52
+ const wsAuthMsg = z.object({
53
+ token: z.string().min(1),
54
+ type: z.literal("auth"),
55
+ });
56
+
57
+ const jwtPayload = z.object({
58
+ bearerToken: z.string().optional(),
59
+ exp: z.number().optional(),
60
+ user: UserSchema,
61
+ });
62
+
63
+ const authPayload = z.object({
64
+ password: z.string().min(1),
65
+ username: z.string().min(1),
66
+ });
67
+
68
+ const deviceAuthPayload = z.object({
69
+ deviceID: z.string().min(1),
70
+ signKey: z.string().min(1),
71
+ });
72
+
73
+ const deviceVerifyPayload = z.object({
74
+ challengeID: z.string().min(1),
75
+ signed: z.string().min(1),
76
+ });
77
+
78
+ const mailPostPayload = z.object({
79
+ header: z.custom<Uint8Array>((val) => val instanceof Uint8Array),
80
+ mail: MailWSSchema,
81
+ });
82
+
83
+ const directories = ["files", "avatars", "emoji"];
84
+ for (const dir of directories) {
85
+ if (!fs.existsSync(dir)) {
86
+ fs.mkdirSync(dir);
87
+ }
88
+ }
89
+
90
+ const getAppVersion = (): string => {
91
+ try {
92
+ const raw = fs.readFileSync(
93
+ new URL("../package.json", import.meta.url),
94
+ {
95
+ encoding: "utf8",
96
+ },
97
+ );
98
+ const pkg: unknown = JSON.parse(raw);
99
+ if (
100
+ typeof pkg === "object" &&
101
+ pkg !== null &&
102
+ "version" in pkg &&
103
+ typeof pkg.version === "string"
104
+ ) {
105
+ return pkg.version;
106
+ }
107
+ return "unknown";
108
+ } catch {
109
+ return "unknown";
110
+ }
111
+ };
112
+
113
+ const getCommitSha = (): string => {
114
+ const sourceDir = path.dirname(fileURLToPath(import.meta.url));
115
+ const repoRoot = path.resolve(sourceDir, "..");
116
+
117
+ try {
118
+ const sha = execSync("git rev-parse --verify --short=12 HEAD", {
119
+ cwd: repoRoot,
120
+ encoding: "utf8",
121
+ stdio: ["ignore", "pipe", "ignore"],
122
+ timeout: 1500,
123
+ }).trim();
124
+ return sha || "unknown";
125
+ } catch {
126
+ return "unknown";
127
+ }
128
+ };
129
+
130
+ export interface SpireOptions {
131
+ apiPort?: number;
132
+ dbType?: "mysql" | "sqlite3" | "sqlite3mem" | "sqlite";
133
+ logLevel?:
134
+ | "debug"
135
+ | "error"
136
+ | "http"
137
+ | "info"
138
+ | "silly"
139
+ | "verbose"
140
+ | "warn";
141
+ }
142
+
143
+ export class Spire extends EventEmitter {
144
+ private actionTokens: ActionToken[] = [];
145
+ private api = express();
146
+ private clients: ClientManager[] = [];
147
+ private readonly commitSha = getCommitSha();
148
+ private db: Database;
149
+ private dbReady = false;
150
+ private deviceChallenges = new Map<
151
+ string,
152
+ { deviceID: string; nonce: string; time: number }
153
+ >();
154
+ private log: winston.Logger;
155
+ private options: SpireOptions | undefined;
156
+
157
+ private queuedRequestIncrements = 0;
158
+ private requestsTotal = 0;
159
+
160
+ private requestsTotalLoaded = false;
161
+
162
+ private server: null | Server = null;
163
+ private signKeys: KeyPair;
164
+
165
+ private readonly startedAt = new Date();
166
+ private readonly version = getAppVersion();
167
+ private wss: WebSocketServer = new WebSocketServer({ noServer: true });
168
+
169
+ constructor(SK: string, options?: SpireOptions) {
170
+ super();
171
+ this.signKeys = xSignKeyPairFromSecret(XUtils.decodeHex(SK));
172
+
173
+ // Trust a single proxy hop (nginx / cloudflare / load balancer).
174
+ // Required so `req.ip` and `express-rate-limit`'s keyGenerator see
175
+ // the real client address via X-Forwarded-For. Never use `true` —
176
+ // that lets attackers spoof the header and bypass rate limiting.
177
+ // If spire is deployed without a proxy, set this to 0 instead.
178
+ this.api.set("trust proxy", 1);
179
+
180
+ this.db = new Database(options);
181
+ this.db.on("ready", () => {
182
+ this.dbReady = true;
183
+ this.bootstrapRequestCounter().catch((err: unknown) => {
184
+ this.log.error(
185
+ "Failed to load persisted request counter: " + String(err),
186
+ );
187
+ });
188
+ });
189
+
190
+ this.log = createLogger("spire", options?.logLevel || "error");
191
+ this.init(options?.apiPort || 16777);
192
+
193
+ this.options = options;
194
+ }
195
+
196
+ public async close(): Promise<void> {
197
+ this.wss.clients.forEach((ws) => {
198
+ ws.terminate();
199
+ });
200
+
201
+ this.wss.on("close", () => {
202
+ this.log.info("ws: closed.");
203
+ });
204
+
205
+ this.server?.on("close", () => {
206
+ this.log.info("http: closed.");
207
+ });
208
+
209
+ this.server?.close();
210
+ this.wss.close();
211
+ await this.db.close();
212
+ return;
213
+ }
214
+
215
+ private async bootstrapRequestCounter(): Promise<void> {
216
+ const persistedTotal = await this.db.getRequestsTotal();
217
+ const startupIncrements = this.queuedRequestIncrements;
218
+ this.queuedRequestIncrements = 0;
219
+ this.requestsTotal = persistedTotal + startupIncrements;
220
+ if (startupIncrements > 0) {
221
+ await this.db.incrementRequestsTotal(startupIncrements);
222
+ }
223
+ this.requestsTotalLoaded = true;
224
+ }
225
+
226
+ private createActionToken(scope: TokenScopes): ActionToken {
227
+ const token: ActionToken = {
228
+ key: crypto.randomUUID(),
229
+ scope,
230
+ time: new Date().toISOString(),
231
+ };
232
+ this.actionTokens.push(token);
233
+ return token;
234
+ }
235
+
236
+ private deleteActionToken(key: ActionToken) {
237
+ if (this.actionTokens.includes(key)) {
238
+ this.actionTokens.splice(this.actionTokens.indexOf(key), 1);
239
+ }
240
+ }
241
+
242
+ private init(apiPort: number): void {
243
+ this.api.use((_req, _res, next) => {
244
+ this.requestsTotal += 1;
245
+
246
+ if (!this.requestsTotalLoaded) {
247
+ this.queuedRequestIncrements += 1;
248
+ } else {
249
+ this.db.incrementRequestsTotal(1).catch((err: unknown) => {
250
+ this.log.warn(
251
+ "Failed to persist request counter increment: " +
252
+ String(err),
253
+ );
254
+ });
255
+ }
256
+
257
+ next();
258
+ });
259
+
260
+ // initialize the expression app configuration with loose routes/handlers
261
+ initApp(
262
+ this.api,
263
+ this.db,
264
+ this.log,
265
+ this.validateToken.bind(this),
266
+ this.signKeys,
267
+ this.notify.bind(this),
268
+ );
269
+
270
+ // WS auth: client sends { type: "auth", token } as first message
271
+ this.wss.on("connection", (ws) => {
272
+ this.log.info("WS connection established, waiting for auth...");
273
+ const AUTH_TIMEOUT = 10_000;
274
+
275
+ const timer = setTimeout(() => {
276
+ this.log.warn("WS auth timeout — closing.");
277
+ ws.close();
278
+ }, AUTH_TIMEOUT);
279
+
280
+ const onFirstMessage = (data: ArrayBuffer | Buffer | Buffer[]) => {
281
+ const str = Buffer.isBuffer(data)
282
+ ? data.toString()
283
+ : data instanceof ArrayBuffer
284
+ ? Buffer.from(data).toString()
285
+ : Buffer.concat(data).toString();
286
+ clearTimeout(timer);
287
+ ws.off("message", onFirstMessage);
288
+
289
+ try {
290
+ const rawParsed: unknown = JSON.parse(str);
291
+ const authResult = wsAuthMsg.safeParse(rawParsed);
292
+ if (!authResult.success) {
293
+ throw new Error(
294
+ "Expected { type: 'auth', token }, got: " +
295
+ JSON.stringify(authResult.error.issues),
296
+ );
297
+ }
298
+ const result = jwt.verify(
299
+ authResult.data.token,
300
+ getJwtSecret(),
301
+ );
302
+ const jwtResult = jwtPayload.safeParse(result);
303
+ if (!jwtResult.success) {
304
+ throw new Error(
305
+ "Invalid JWT payload: " +
306
+ JSON.stringify(jwtResult.error.issues),
307
+ );
308
+ }
309
+ const userDetails: User = jwtResult.data.user;
310
+
311
+ this.log.info(
312
+ "WS auth succeeded for " + userDetails.username,
313
+ );
314
+
315
+ const client = new ClientManager(
316
+ ws,
317
+ this.db,
318
+ this.notify.bind(this),
319
+ userDetails,
320
+ this.options,
321
+ );
322
+
323
+ client.on("fail", () => {
324
+ this.log.info(
325
+ "Client connection is down, removing: " +
326
+ client.toString(),
327
+ );
328
+ if (this.clients.includes(client)) {
329
+ this.clients.splice(
330
+ this.clients.indexOf(client),
331
+ 1,
332
+ );
333
+ }
334
+ this.log.info(
335
+ "Current authorized clients: " +
336
+ String(this.clients.length),
337
+ );
338
+ });
339
+
340
+ client.on("authed", () => {
341
+ this.log.info(
342
+ "New client authorized: " + client.toString(),
343
+ );
344
+ this.clients.push(client);
345
+ this.log.info(
346
+ "Current authorized clients: " +
347
+ String(this.clients.length),
348
+ );
349
+ });
350
+ } catch (err: unknown) {
351
+ this.log.warn("WS auth failed: " + String(err));
352
+ const errMsg: BaseMsg = {
353
+ transmissionID: crypto.randomUUID(),
354
+ type: "unauthorized",
355
+ };
356
+ ws.send(XUtils.packMessage(errMsg));
357
+ ws.close();
358
+ }
359
+ };
360
+
361
+ ws.on("message", onFirstMessage);
362
+ ws.on("close", () => {
363
+ clearTimeout(timer);
364
+ });
365
+ });
366
+
367
+ this.api.get(
368
+ "/token/:tokenType",
369
+ (req, res, next) => {
370
+ if (getParam(req, "tokenType") !== "register") {
371
+ protect(req, res, next);
372
+ } else {
373
+ next();
374
+ }
375
+ },
376
+ (req, res) => {
377
+ const allowedTokens = [
378
+ "file",
379
+ "register",
380
+ "avatar",
381
+ "device",
382
+ "invite",
383
+ "emoji",
384
+ "connect",
385
+ ];
386
+
387
+ const tokenType = getParam(req, "tokenType");
388
+
389
+ if (!allowedTokens.includes(tokenType)) {
390
+ res.sendStatus(400);
391
+ return;
392
+ }
393
+
394
+ let scope;
395
+
396
+ switch (tokenType) {
397
+ case "avatar":
398
+ scope = TokenScopes.Avatar;
399
+ break;
400
+ case "connect":
401
+ scope = TokenScopes.Connect;
402
+ break;
403
+ case "device":
404
+ scope = TokenScopes.Device;
405
+ break;
406
+ case "emoji":
407
+ scope = TokenScopes.Emoji;
408
+ break;
409
+ case "file":
410
+ scope = TokenScopes.File;
411
+ break;
412
+ case "invite":
413
+ scope = TokenScopes.Invite;
414
+ break;
415
+ case "register":
416
+ scope = TokenScopes.Register;
417
+ break;
418
+ default:
419
+ res.sendStatus(400);
420
+ return;
421
+ }
422
+
423
+ try {
424
+ this.log.info("New token requested of type " + tokenType);
425
+ const token = this.createActionToken(scope);
426
+ this.log.info("New token created: " + token.key);
427
+
428
+ setTimeout(() => {
429
+ this.deleteActionToken(token);
430
+ }, TOKEN_EXPIRY);
431
+
432
+ const acceptHeader = req.get("accept")?.toLowerCase() || "";
433
+ const wantsJson =
434
+ acceptHeader.includes("application/json") &&
435
+ !acceptHeader.includes("application/msgpack") &&
436
+ !acceptHeader.includes("*/*");
437
+
438
+ if (wantsJson) {
439
+ return res.json(token);
440
+ }
441
+
442
+ res.set("Content-Type", "application/msgpack");
443
+ return res.send(msgpack.encode(token));
444
+ } catch (err: unknown) {
445
+ this.log.error(String(err));
446
+ return res.sendStatus(500);
447
+ }
448
+ },
449
+ );
450
+
451
+ this.api.post("/whoami", (req, res) => {
452
+ if (!req.user) {
453
+ res.sendStatus(401);
454
+ return;
455
+ }
456
+
457
+ res.send(
458
+ msgpack.encode({
459
+ exp: req.exp,
460
+ token: req.bearerToken,
461
+ user: req.user,
462
+ }),
463
+ );
464
+ });
465
+
466
+ this.api.get("/healthz", (_req, res) => {
467
+ if (!this.dbReady) {
468
+ res.status(503).json({ dbReady: false, ok: false });
469
+ return;
470
+ }
471
+ res.json({ dbReady: true, ok: true });
472
+ });
473
+
474
+ this.api.get("/status", async (_req, res) => {
475
+ const started = Date.now();
476
+ const dbHealthy = this.dbReady ? await this.db.isHealthy() : false;
477
+ const checkDurationMs = Date.now() - started;
478
+
479
+ const ok = dbHealthy;
480
+ res.json({
481
+ checkDurationMs,
482
+ commitSha: this.commitSha,
483
+ dbHealthy,
484
+ dbReady: this.dbReady,
485
+ latencyBudgetMs: STATUS_LATENCY_BUDGET_MS,
486
+ metrics: {
487
+ requestsTotal: this.requestsTotal,
488
+ },
489
+ now: new Date(),
490
+ ok,
491
+ startedAt: this.startedAt.toISOString(),
492
+ uptimeSeconds: Math.floor(process.uptime()),
493
+ version: this.version,
494
+ withinLatencyBudget:
495
+ checkDurationMs <= STATUS_LATENCY_BUDGET_MS,
496
+ });
497
+ });
498
+
499
+ this.api.post("/goodbye", protect, (req, res) => {
500
+ jwt.sign({ user: req.user }, getJwtSecret(), { expiresIn: -1 });
501
+ res.sendStatus(200);
502
+ });
503
+
504
+ // ── Device-key auth ──────────────────────────────────────────
505
+
506
+ this.api.post("/auth/device", authLimiter, async (req, res) => {
507
+ try {
508
+ const parsed = deviceAuthPayload.safeParse(req.body);
509
+ if (!parsed.success) {
510
+ return res
511
+ .status(400)
512
+ .send({ error: "deviceID and signKey required." });
513
+ }
514
+ const { deviceID, signKey } = parsed.data;
515
+
516
+ const device = await this.db.retrieveDevice(deviceID);
517
+ if (!device || device.signKey !== signKey) {
518
+ return res.status(404).send({ error: "Device not found." });
519
+ }
520
+
521
+ // Generate challenge nonce (32 bytes)
522
+ const nonce = XUtils.encodeHex(xRandomBytes(32));
523
+ const challengeID = crypto.randomUUID();
524
+ this.deviceChallenges.set(challengeID, {
525
+ deviceID,
526
+ nonce,
527
+ time: Date.now(),
528
+ });
529
+
530
+ // Clean up expired challenges
531
+ setTimeout(() => {
532
+ this.deviceChallenges.delete(challengeID);
533
+ }, DEVICE_CHALLENGE_EXPIRY);
534
+
535
+ this.log.info("Device challenge issued for " + deviceID);
536
+ return res.send(
537
+ msgpack.encode({ challenge: nonce, challengeID }),
538
+ );
539
+ } catch (err: unknown) {
540
+ this.log.error("Device challenge error: " + String(err));
541
+ return res.sendStatus(500);
542
+ }
543
+ });
544
+
545
+ this.api.post("/auth/device/verify", authLimiter, async (req, res) => {
546
+ try {
547
+ const parsed = deviceVerifyPayload.safeParse(req.body);
548
+ if (!parsed.success) {
549
+ return res.status(400).send({
550
+ error: "challengeID and signed required.",
551
+ });
552
+ }
553
+ const { challengeID, signed } = parsed.data;
554
+
555
+ const challenge = this.deviceChallenges.get(challengeID);
556
+ if (!challenge) {
557
+ return res.status(401).send({
558
+ error: "Challenge expired or not found.",
559
+ });
560
+ }
561
+
562
+ // Consume the challenge (single-use)
563
+ this.deviceChallenges.delete(challengeID);
564
+
565
+ // Check expiry
566
+ if (Date.now() - challenge.time > DEVICE_CHALLENGE_EXPIRY) {
567
+ return res
568
+ .status(401)
569
+ .send({ error: "Challenge expired." });
570
+ }
571
+
572
+ // Look up the device to get its public signKey
573
+ const device = await this.db.retrieveDevice(challenge.deviceID);
574
+ if (!device) {
575
+ return res.status(404).send({ error: "Device not found." });
576
+ }
577
+
578
+ // Verify the Ed25519 signature
579
+ const opened = xSignOpen(
580
+ XUtils.decodeHex(signed),
581
+ XUtils.decodeHex(device.signKey),
582
+ );
583
+ if (!opened) {
584
+ return res
585
+ .status(401)
586
+ .send({ error: "Signature verification failed." });
587
+ }
588
+
589
+ // Verify the signed content matches the challenge nonce
590
+ const signedNonce = XUtils.encodeHex(opened);
591
+ if (signedNonce !== challenge.nonce) {
592
+ return res
593
+ .status(401)
594
+ .send({ error: "Challenge mismatch." });
595
+ }
596
+
597
+ // Look up device owner
598
+ const user = await this.db.retrieveUser(device.owner);
599
+ if (!user) {
600
+ return res
601
+ .status(404)
602
+ .send({ error: "Device owner not found." });
603
+ }
604
+
605
+ // Issue short-lived JWT (1 hour, not 7 days)
606
+ const token = jwt.sign(
607
+ { user: censorUser(user) },
608
+ getJwtSecret(),
609
+ { expiresIn: DEVICE_AUTH_JWT_EXPIRY },
610
+ );
611
+ this.log.info(
612
+ "Device-key auth succeeded for " +
613
+ user.username +
614
+ " (device " +
615
+ device.deviceID +
616
+ ")",
617
+ );
618
+ return res.send(
619
+ msgpack.encode({ token, user: censorUser(user) }),
620
+ );
621
+ } catch (err: unknown) {
622
+ this.log.error("Device verify error: " + String(err));
623
+ return res.sendStatus(500);
624
+ }
625
+ });
626
+
627
+ this.api.post("/mail", protect, async (req, res) => {
628
+ const senderDeviceDetails = req.device;
629
+ if (!senderDeviceDetails) {
630
+ res.sendStatus(401);
631
+ return;
632
+ }
633
+ const authorUserDetails = getUser(req);
634
+
635
+ const parsed = mailPostPayload.safeParse(req.body);
636
+ if (!parsed.success) {
637
+ res.status(400).json({
638
+ error: "Invalid mail payload",
639
+ issues: parsed.error.issues,
640
+ });
641
+ return;
642
+ }
643
+ const { header, mail } = parsed.data;
644
+
645
+ await this.db.saveMail(
646
+ mail,
647
+ header,
648
+ senderDeviceDetails.deviceID,
649
+ authorUserDetails.userID,
650
+ );
651
+ this.log.info("Received mail for " + mail.recipient);
652
+
653
+ const recipientDeviceDetails = await this.db.retrieveDevice(
654
+ mail.recipient,
655
+ );
656
+ if (!recipientDeviceDetails) {
657
+ res.sendStatus(400);
658
+ return;
659
+ }
660
+
661
+ res.sendStatus(200);
662
+ this.notify(
663
+ recipientDeviceDetails.owner,
664
+ "mail",
665
+ crypto.randomUUID(),
666
+ null,
667
+ mail.recipient,
668
+ );
669
+ });
670
+
671
+ this.api.post("/auth", authLimiter, async (req, res) => {
672
+ const parsed = authPayload.safeParse(req.body);
673
+ if (!parsed.success) {
674
+ res.status(400).json({
675
+ error: "Invalid credentials format",
676
+ });
677
+ return;
678
+ }
679
+ const { password, username } = parsed.data;
680
+
681
+ try {
682
+ const userEntry = await this.db.retrieveUser(username);
683
+ if (!userEntry) {
684
+ res.sendStatus(404);
685
+ this.log.warn("User does not exist.");
686
+ return;
687
+ }
688
+
689
+ const salt = XUtils.decodeHex(userEntry.passwordSalt);
690
+ const payloadHash = XUtils.encodeHex(
691
+ hashPassword(password, salt),
692
+ );
693
+
694
+ if (payloadHash !== userEntry.passwordHash) {
695
+ res.sendStatus(401);
696
+ return;
697
+ }
698
+
699
+ const token = jwt.sign(
700
+ { user: censorUser(userEntry) },
701
+ getJwtSecret(),
702
+ { expiresIn: JWT_EXPIRY },
703
+ );
704
+
705
+ // just to make sure
706
+ jwt.verify(token, getJwtSecret());
707
+
708
+ res.send(
709
+ msgpack.encode({ token, user: censorUser(userEntry) }),
710
+ );
711
+ } catch (err: unknown) {
712
+ this.log.error(String(err));
713
+ res.sendStatus(500);
714
+ }
715
+ });
716
+
717
+ this.api.post("/register", authLimiter, async (req, res) => {
718
+ try {
719
+ const regParsed = RegistrationPayloadSchema.safeParse(req.body);
720
+ if (!regParsed.success) {
721
+ res.status(400).json({
722
+ error: "Invalid registration payload",
723
+ issues: regParsed.error.issues,
724
+ });
725
+ return;
726
+ }
727
+ const regPayload = regParsed.data;
728
+ if (!usernameRegex.test(regPayload.username)) {
729
+ res.status(400).send({
730
+ error: "Username must be between three and nineteen letters, digits, or underscores.",
731
+ });
732
+ return;
733
+ }
734
+
735
+ const regKey = xSignOpen(
736
+ XUtils.decodeHex(regPayload.signed),
737
+ XUtils.decodeHex(regPayload.signKey),
738
+ );
739
+
740
+ if (
741
+ regKey &&
742
+ this.validateToken(
743
+ uuidStringify(regKey),
744
+ TokenScopes.Register,
745
+ )
746
+ ) {
747
+ const [user, err] = await this.db.createUser(
748
+ regKey,
749
+ regPayload,
750
+ );
751
+ if (err !== null) {
752
+ const errCode =
753
+ "code" in err && typeof err.code === "string"
754
+ ? err.code
755
+ : undefined;
756
+ switch (errCode) {
757
+ case "ER_DUP_ENTRY":
758
+ const usernameConflict = String(err).includes(
759
+ "users_username_unique",
760
+ );
761
+ const signKeyConflict = String(err).includes(
762
+ "users_signkey_unique",
763
+ );
764
+
765
+ this.log.warn(
766
+ "User attempted to register duplicate account.",
767
+ );
768
+ if (usernameConflict) {
769
+ res.status(400).send({
770
+ error: "Username is already registered.",
771
+ });
772
+ return;
773
+ }
774
+ if (signKeyConflict) {
775
+ res.status(400).send({
776
+ error: "Public key is already registered.",
777
+ });
778
+ return;
779
+ }
780
+ res.status(500).send({
781
+ error: "An error occurred registering.",
782
+ });
783
+ break;
784
+ default:
785
+ this.log.info(
786
+ "Unsupported sql error type: " +
787
+ String(errCode),
788
+ );
789
+ this.log.error(String(err));
790
+ res.sendStatus(500);
791
+ break;
792
+ }
793
+ } else {
794
+ this.log.info("Registration success.");
795
+ if (!user) {
796
+ res.sendStatus(500);
797
+ return;
798
+ }
799
+ res.send(msgpack.encode(censorUser(user)));
800
+ }
801
+ } else {
802
+ res.status(400).send({
803
+ error: "Invalid or no token supplied.",
804
+ });
805
+ }
806
+ } catch (err: unknown) {
807
+ this.log.error("error registering user: " + String(err));
808
+ res.sendStatus(500);
809
+ }
810
+ });
811
+
812
+ this.server = this.api.listen(apiPort, () => {
813
+ this.log.info("API started on port " + String(apiPort));
814
+ });
815
+
816
+ // Accept all WS upgrades — auth happens post-connection.
817
+ this.server.on("upgrade", (req, socket, head) => {
818
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
819
+ this.wss.emit("connection", ws);
820
+ });
821
+ });
822
+ }
823
+
824
+ private notify(
825
+ userID: string,
826
+ event: string,
827
+ transmissionID: string,
828
+ data?: unknown,
829
+ deviceID?: string,
830
+ ): void {
831
+ for (const client of this.clients) {
832
+ if (deviceID) {
833
+ if (client.getDevice().deviceID === deviceID) {
834
+ const msg: NotifyMsg = {
835
+ data,
836
+ event,
837
+ transmissionID,
838
+ type: "notify",
839
+ };
840
+ client.send(msg);
841
+ }
842
+ } else {
843
+ if (client.getUser().userID === userID) {
844
+ const msg: NotifyMsg = {
845
+ data,
846
+ event,
847
+ transmissionID,
848
+ type: "notify",
849
+ };
850
+ client.send(msg);
851
+ }
852
+ }
853
+ }
854
+ }
855
+
856
+ private validateToken(key: string, scope: TokenScopes): boolean {
857
+ this.log.info("Validating token: " + key);
858
+ for (const rKey of this.actionTokens) {
859
+ if (rKey.key === key) {
860
+ if (rKey.scope !== scope) {
861
+ continue;
862
+ }
863
+
864
+ const age = Date.now() - new Date(rKey.time).getTime();
865
+ this.log.info("Token found, " + String(age) + " ms old.");
866
+ if (age < TOKEN_EXPIRY) {
867
+ this.log.info("Token is valid.");
868
+ this.deleteActionToken(rKey);
869
+ return true;
870
+ } else {
871
+ this.log.info("Token is expired.");
872
+ }
873
+ }
874
+ }
875
+ this.log.info("Token not found.");
876
+ return false;
877
+ }
878
+ }