@vex-chat/spire 0.8.0 → 1.0.1

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