@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.
- package/README.md +82 -26
- package/dist/ClientManager.d.ts +23 -25
- package/dist/ClientManager.js +230 -249
- package/dist/ClientManager.js.map +1 -0
- package/dist/Database.d.ts +49 -47
- package/dist/Database.js +698 -773
- package/dist/Database.js.map +1 -0
- package/dist/Spire.d.ts +22 -14
- package/dist/Spire.js +496 -236
- package/dist/Spire.js.map +1 -0
- package/dist/__tests__/Database.spec.js +116 -75
- package/dist/__tests__/Database.spec.js.map +1 -0
- package/dist/db/schema.d.ts +134 -0
- package/dist/db/schema.js +2 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -5
- package/dist/index.js.map +1 -0
- package/dist/middleware/validate.d.ts +12 -0
- package/dist/middleware/validate.js +35 -0
- package/dist/middleware/validate.js.map +1 -0
- package/dist/migrations/2026-04-06_initial-schema.d.ts +3 -0
- package/dist/migrations/2026-04-06_initial-schema.js +192 -0
- package/dist/migrations/2026-04-06_initial-schema.js.map +1 -0
- package/dist/run.js +26 -21
- package/dist/run.js.map +1 -0
- package/dist/server/avatar.d.ts +3 -4
- package/dist/server/avatar.js +64 -64
- package/dist/server/avatar.js.map +1 -0
- package/dist/server/errors.d.ts +59 -0
- package/dist/server/errors.js +94 -0
- package/dist/server/errors.js.map +1 -0
- package/dist/server/file.d.ts +3 -4
- package/dist/server/file.js +81 -62
- package/dist/server/file.js.map +1 -0
- package/dist/server/index.d.ts +8 -10
- package/dist/server/index.js +414 -405
- package/dist/server/index.js.map +1 -0
- package/dist/server/invite.d.ts +4 -5
- package/dist/server/invite.js +18 -52
- package/dist/server/invite.js.map +1 -0
- package/dist/server/openapi.d.ts +2 -0
- package/dist/server/openapi.js +40 -0
- package/dist/server/openapi.js.map +1 -0
- package/dist/server/permissions.d.ts +16 -0
- package/dist/server/permissions.js +22 -0
- package/dist/server/permissions.js.map +1 -0
- package/dist/server/rateLimit.d.ts +28 -0
- package/dist/server/rateLimit.js +58 -0
- package/dist/server/rateLimit.js.map +1 -0
- package/dist/server/user.d.ts +4 -7
- package/dist/server/user.js +55 -66
- package/dist/server/user.js.map +1 -0
- package/dist/server/utils.d.ts +35 -7
- package/dist/server/utils.js +50 -6
- package/dist/server/utils.js.map +1 -0
- package/dist/types/express.d.ts +20 -0
- package/dist/types/express.js +2 -0
- package/dist/types/express.js.map +1 -0
- package/dist/utils/createLogger.js +13 -19
- package/dist/utils/createLogger.js.map +1 -0
- package/dist/utils/createUint8UUID.js +6 -10
- package/dist/utils/createUint8UUID.js.map +1 -0
- package/dist/utils/jwtSecret.d.ts +7 -0
- package/dist/utils/jwtSecret.js +15 -0
- package/dist/utils/jwtSecret.js.map +1 -0
- package/dist/utils/loadEnv.js +7 -22
- package/dist/utils/loadEnv.js.map +1 -0
- package/dist/utils/msgpack.d.ts +2 -0
- package/dist/utils/msgpack.js +4 -0
- package/dist/utils/msgpack.js.map +1 -0
- package/package.json +91 -65
- package/src/ClientManager.ts +434 -0
- package/src/Database.ts +925 -0
- package/src/Spire.ts +878 -0
- package/src/__tests__/Database.spec.ts +167 -0
- package/src/ambient-modules.d.ts +1 -0
- package/src/db/schema.ts +165 -0
- package/src/index.ts +3 -0
- package/src/middleware/validate.ts +38 -0
- package/src/migrations/2026-04-06_initial-schema.ts +218 -0
- package/src/run.ts +37 -0
- package/src/server/avatar.ts +141 -0
- package/src/server/errors.ts +133 -0
- package/src/server/file.ts +172 -0
- package/src/server/index.ts +855 -0
- package/src/server/invite.ts +65 -0
- package/src/server/openapi.ts +51 -0
- package/src/server/permissions.ts +40 -0
- package/src/server/rateLimit.ts +86 -0
- package/src/server/user.ts +125 -0
- package/src/server/utils.ts +59 -0
- package/src/types/express.ts +23 -0
- package/src/utils/createLogger.ts +47 -0
- package/src/utils/createUint8UUID.ts +9 -0
- package/src/utils/jwtSecret.ts +16 -0
- package/src/utils/loadEnv.ts +15 -0
- package/src/utils/msgpack.ts +4 -0
- package/avatars/052242d0-4129-4a6e-8076-22709c157549 +0 -0
- package/avatars/0a677c9a-4986-4b2c-ae2e-12faf22f55db +0 -0
- package/avatars/0ba21b91-decb-4a3e-ac86-4dd54d805a9a +0 -0
- package/avatars/0c48d8b6-1d1b-4297-a6c6-2fe50af6fc35 +0 -0
- package/avatars/0d993cdf-19a6-4299-a4ee-a06579d106cf +0 -0
- package/avatars/17b000e4-ac38-46ec-9dec-d2568086129a +0 -0
- package/avatars/19dc5594-0f06-4ac6-af18-8740dd39ef6b +0 -0
- package/avatars/20444fa3-6d5e-429e-b55f-b81c3d2c61ee +0 -0
- package/avatars/21c0512a-5630-4931-9442-d66db66737be +0 -0
- package/avatars/22830a60-0b6f-4912-83a5-72245465f332 +0 -0
- package/avatars/243639ce-f59f-4404-a1f1-4ec0eb5d2af3 +0 -0
- package/avatars/30d2c01d-7b7f-4ea9-9859-1c90837a23f7 +0 -0
- package/avatars/315a04f0-9a6f-4b0f-bb9f-5fa774c4752b +0 -0
- package/avatars/3563d333-53fe-4885-ac2d-9a4f761db85e +0 -0
- package/avatars/36a10c00-3b4c-437f-8e1f-4428ecde0003 +0 -0
- package/avatars/40b83eeb-c6e8-4268-82ab-69799a796405 +0 -0
- package/avatars/45b5ddb9-ad2c-4404-8ab3-cb4699e6d61c +0 -0
- package/avatars/4e4f0ffb-9a75-479a-bccb-446d0bf85020 +0 -0
- package/avatars/4e62c3bd-08c6-4fdd-bd65-f01c7322ed64 +0 -0
- package/avatars/5004d2e7-51af-44af-8776-6c71f7019843 +0 -0
- package/avatars/5041eb29-5c4b-4dea-8c1b-31ba4473161a +0 -0
- package/avatars/5065cf78-31c5-46cb-8d5c-c0b6be2d994e +0 -0
- package/avatars/51b91d2c-8956-4d73-b4ad-ca6a8d9da9a8 +0 -0
- package/avatars/58264a2c-5651-4a42-8ca2-a9907b311e48 +0 -0
- package/avatars/58c2357c-8080-4725-a0ce-182c96b037c4 +0 -0
- package/avatars/59b5f6dd-8e04-4d15-b4dc-c1c652558a74 +0 -0
- package/avatars/5b417a78-b274-48bc-98a4-6e54b74ee62d +0 -0
- package/avatars/611f5a93-1ed4-45a1-bc8e-e8e413f9b171 +0 -0
- package/avatars/65abe919-9921-46f6-9bc9-183e9cc53c8a +0 -0
- package/avatars/6934202d-1546-4270-8a15-97ba8b8c6fa9 +0 -0
- package/avatars/6acd24be-f4b7-4399-9e7c-807821828d29 +0 -0
- package/avatars/6b2d6ac5-e35c-4297-994e-f0eb6fe56740 +0 -0
- package/avatars/6cae9ddd-f163-44e7-a632-30425716a159 +0 -0
- package/avatars/6d90e79e-b9c1-4b89-843b-96636de8d26b +0 -0
- package/avatars/6f82c7bb-a974-4372-8a64-ce287e668c8c +0 -0
- package/avatars/74a45091-5a76-4bb7-ae8a-cd7373adc128 +0 -0
- package/avatars/7d071ab2-e0b5-4dbb-8bf5-1fae50c3663f +0 -0
- package/avatars/7de818c8-bda1-4f51-976e-160fc087184b +0 -0
- package/avatars/873937df-8cb1-427a-92bf-f829f4259624 +0 -0
- package/avatars/8b45ffa5-3322-4109-bfa0-1be088336135 +0 -0
- package/avatars/8efaa426-22a9-42a2-b4ac-a275717b812f +0 -0
- package/avatars/903fd1a6-d6ea-431c-b98f-f21e424a2852 +0 -0
- package/avatars/943d8533-5174-4199-990f-1ec69e5d60c4 +0 -0
- package/avatars/952d014e-3804-4cd2-a4a0-ffe40a11e4ac +0 -0
- package/avatars/95d3acb0-724d-4413-b20f-edad55812d5d +0 -0
- package/avatars/9641a946-f613-471b-bedd-c1730b96b51e +0 -0
- package/avatars/9b01cbbf-f6b2-43fb-b569-589b6f2a8134 +0 -0
- package/avatars/9cd4424d-a34f-4467-acc0-93cf82703e0d +0 -0
- package/avatars/9d9ad3b0-e5a6-420a-a6c5-fb9085b70376 +0 -0
- package/avatars/9e9e34b5-4e63-4c4b-9722-c7f5674b47aa +0 -0
- package/avatars/a387d5c1-59eb-4a6b-80c1-a8982ed12c33 +0 -0
- package/avatars/a3e86d21-d881-4824-8ebf-45e3bf0f9186 +0 -0
- package/avatars/a8d5cc1c-3f42-4b7b-8d33-f9a9ef77f96b +0 -0
- package/avatars/a91d815f-badc-4604-a7be-6c7a44e6101d +0 -0
- package/avatars/aa8d0324-bcec-4737-a8c4-bdbff914148d +0 -0
- package/avatars/abb8a941-8b6b-47d7-a2f9-8b153ba44aa2 +0 -0
- package/avatars/b011bb38-1ef3-4d22-82fa-8bf60faf7b5d +0 -0
- package/avatars/b24bcbc1-11f0-473d-a8b9-ba8ce4ca127d +0 -0
- package/avatars/b2607346-af1c-4e98-b725-7650a766db2a +0 -0
- package/avatars/b6300f7c-cb37-459b-b1bf-8a0a0e797a52 +0 -0
- package/avatars/b7d3cff3-84dd-4547-93a1-de1aaa8aa34c +0 -0
- package/avatars/baa4b51c-e97f-4f51-bcb3-f27bc506cfaf +0 -0
- package/avatars/be7022e4-e292-4515-80d5-f9b61ebeb4ce +0 -0
- package/avatars/bed596a3-7569-4854-9e76-f52d33c0a541 +0 -0
- package/avatars/bf69992f-3f72-4930-99bb-0ffe17f3aebf +0 -0
- package/avatars/ca00c250-c6d4-464d-a6de-1c8467a18fe8 +0 -0
- package/avatars/ca19d78f-c0bc-4bd5-b26f-6923cb19996d +0 -0
- package/avatars/cda4d6e1-e0a4-4024-ac95-6de98e713b98 +0 -0
- package/avatars/cf72c30d-2da8-4e81-aa71-735b9e714274 +0 -0
- package/avatars/d5a35b78-99b3-4564-b6b9-b2ccab28c470 +0 -0
- package/avatars/dadb38c1-2a9d-47a3-8d92-b56b6166973c +0 -0
- package/avatars/e68705c7-375d-4423-9a86-29a16bd3ee0e +0 -0
- package/avatars/e9af3e4c-1f62-4302-8b99-b68ce93b7a86 +0 -0
- package/avatars/ea7e7331-e845-4189-8248-5f5b1d63f5e3 +0 -0
- package/avatars/ef4f8dcb-ef6c-4e7a-9be1-0476161bfce5 +0 -0
- package/avatars/ef7d0917-a206-4f88-8b60-93f8253774dc +0 -0
- package/avatars/f1a554d6-1db3-4ff5-b0dc-b607d6c3b4ff +0 -0
- package/avatars/f1fecc21-c81f-49a8-88f3-f942a0a679f6 +0 -0
- package/avatars/f30a2427-1755-4053-813e-129a179e1dd3 +0 -0
- package/avatars/f5370717-5109-46a5-a8d7-e1dd996d0615 +0 -0
- package/avatars/f6dd7126-1144-4998-bbd3-d4e0fbee2e95 +0 -0
- package/avatars/f83413d5-0003-4756-9ece-745fd61cc468 +0 -0
- package/avatars/f9b3149e-7ec8-4bb3-a9b9-dcbe66dac197 +0 -0
- package/avatars/fa41d70b-857e-4423-bd7d-26ddcddc13b9 +0 -0
- package/avatars/fb551ee8-99c7-400b-8e1d-322ce4619998 +0 -0
- package/avatars/fe83a6d4-abb0-4ab0-b61d-76a7cc08be84 +0 -0
- package/dist/migrations/20210103192527_users.d.ts +0 -3
- package/dist/migrations/20210103192527_users.js +0 -30
- package/dist/migrations/20210103193502_mail.d.ts +0 -3
- package/dist/migrations/20210103193502_mail.js +0 -35
- package/dist/migrations/20210103193525_preKeys.d.ts +0 -3
- package/dist/migrations/20210103193525_preKeys.js +0 -30
- package/dist/migrations/20210103193553_oneTimeKeys.d.ts +0 -3
- package/dist/migrations/20210103193553_oneTimeKeys.js +0 -30
- package/dist/migrations/20210103193615_servers.d.ts +0 -3
- package/dist/migrations/20210103193615_servers.js +0 -28
- package/dist/migrations/20210103193729_channels.d.ts +0 -3
- package/dist/migrations/20210103193729_channels.js +0 -28
- package/dist/migrations/20210103193749_permissions.d.ts +0 -3
- package/dist/migrations/20210103193749_permissions.js +0 -30
- package/dist/migrations/20210103193801_files.d.ts +0 -3
- package/dist/migrations/20210103193801_files.js +0 -28
- package/emoji/04d98632-2c86-421b-a407-17f14fe86f8f +0 -0
- package/emoji/1160ed6e-1163-4043-9808-4029e863ed30 +0 -0
- package/emoji/1547ab18-1635-4a80-a82d-ebbb767b9932 +0 -0
- package/emoji/16922521-f6cb-4de4-860c-27916b22c6ba +0 -0
- package/emoji/198a9432-0e41-4866-994a-448d4775afcb +0 -0
- package/emoji/1be886b3-c9c5-4593-b516-f357ed931f96 +0 -0
- package/emoji/1c2b3d1d-637f-4103-b066-4bc4511a3ad7 +0 -0
- package/emoji/1efd27e7-b15f-475c-8b32-9159d26b169d +0 -0
- package/emoji/270b9409-0ea5-4be2-a239-a8dce13f9c31 +0 -0
- package/emoji/27812f76-fee2-49dd-a217-363de6d159dc +0 -0
- package/emoji/297ec202-8c24-44c6-aead-689d6d461883 +0 -0
- package/emoji/2bf06d86-17cb-4f40-a5ef-bd75d239a1a3 +0 -0
- package/emoji/31a75163-1cce-4dc1-b0a2-ecad6a4c500b +0 -0
- package/emoji/35235635-fdbd-4273-8428-f3cb3e1e8fd3 +0 -0
- package/emoji/3690fff2-6824-4403-a6e3-16a6a54979a9 +0 -0
- package/emoji/391014c2-59e0-46a8-85ec-7a7fdaca1d2d +0 -0
- package/emoji/3b383dcb-6e76-4e85-8e16-7c68040c06c2 +0 -0
- package/emoji/42d617a7-b104-42f5-9618-473181f752cf +0 -0
- package/emoji/482495d3-cce9-4f88-bf2a-f6003f03a9b5 +0 -0
- package/emoji/48390e06-0efb-404c-89bd-5f2be241bd50 +0 -0
- package/emoji/4b808d8d-3248-4149-b919-71b108391bcf +0 -0
- package/emoji/4bc13544-d82a-4e32-bd17-a70592274314 +0 -0
- package/emoji/4fcebf70-8623-4343-8243-67c8547b2edd +0 -0
- package/emoji/509d09aa-1214-459c-8081-50918a17b9af +0 -0
- package/emoji/5272abd8-d4d7-4b90-acd2-bf30e6c27243 +0 -0
- package/emoji/53c272ce-48bd-4d7e-bfb8-a6482b88be54 +0 -0
- package/emoji/5b279e65-06f7-4b26-8b4c-d1b48fba728d +0 -0
- package/emoji/5bd141f9-4394-4108-9376-66ebbc2c2bc1 +0 -0
- package/emoji/5c769156-f9cb-40bd-ab89-4edeece613fd +0 -0
- package/emoji/5c85fba9-8ba7-4fc9-b1b2-48dc30d24a1b +0 -0
- package/emoji/61a5e565-d20b-40ba-a139-b0c73a6027f3 +0 -0
- package/emoji/6913f43d-dd45-456c-9641-a126104d9ae5 +0 -0
- package/emoji/6957e74e-9622-492d-a950-242db3752260 +0 -0
- package/emoji/6a14bab5-26af-4bfa-9c17-be7c2511976d +0 -0
- package/emoji/6be09439-509e-4095-a30a-b1c7c573895d +0 -0
- package/emoji/6cc435b1-fe53-433c-b5a9-2b2019053997 +0 -0
- package/emoji/74f1b2af-bc7e-4a0f-802a-64ded185d5e2 +0 -0
- package/emoji/7890ba09-f02f-428e-807d-006d03d51d4a +0 -0
- package/emoji/7dc69179-6b3c-4f40-b20b-0ff573deea2d +0 -0
- package/emoji/7dd1b6b1-439d-4279-916a-995408863172 +0 -0
- package/emoji/820498ad-a2c8-43a2-ab83-d26f9c2246d4 +0 -0
- package/emoji/8319469c-2787-44e5-91a6-c8c39810dd7c +0 -0
- package/emoji/86745d1d-9e59-4607-b2b0-46c741079be1 +0 -0
- package/emoji/887b3cff-ae9e-4b5f-ad00-3ca9fc72f689 +0 -0
- package/emoji/8c6cf621-71d6-4fca-abe6-e19f4dd7f883 +0 -0
- package/emoji/8ca6d32e-a1ef-4956-a416-d8d0d680f085 +0 -0
- package/emoji/8d979f5e-38a2-4dd1-b3e4-80938bbe499b +0 -0
- package/emoji/99f68ea0-e3fe-4f03-9cc3-5f7f5315404d +0 -0
- package/emoji/9ad3aedc-7f79-4d68-a144-82e5b5dc3033 +0 -0
- package/emoji/9e418c2f-1f0f-46c4-be39-3bda38a28545 +0 -0
- package/emoji/a1f616bf-7402-4e24-9111-18acaebabb48 +0 -0
- package/emoji/a25ed9c1-3f9c-4e5f-ade2-7b159fb9fbf4 +0 -0
- package/emoji/a5176bc2-39a8-467b-8c75-6fbbc81b59c7 +0 -0
- package/emoji/a584215c-6547-438b-8ae8-dd490b51890e +0 -0
- package/emoji/a739895f-cf61-4b7c-b350-8e8283aaf751 +0 -0
- package/emoji/aaa10dd2-02a2-499e-9e17-c83787436508 +0 -0
- package/emoji/ae90baf2-a0ef-4d4d-9cc4-94f8ddd60f45 +0 -0
- package/emoji/b0564c48-feae-431a-95f9-df597c6c124c +0 -0
- package/emoji/b218bb93-e69c-4793-a669-83316650c4e7 +0 -0
- package/emoji/b2998c27-85d5-4598-ab41-469aa8e0fcad +0 -0
- package/emoji/b3da08ba-4179-4b5d-826f-5fc15e1a3ad2 +0 -0
- package/emoji/b840eb6a-a917-4bb2-854b-8f1022e7904b +0 -0
- package/emoji/b84baa76-b4b0-4b83-bc83-78661cb4f1d4 +0 -0
- package/emoji/baf69d80-8b1d-4032-855f-605cf0d489c3 +0 -0
- package/emoji/bb4d372c-ccd0-4a47-b157-b6a3b9f763e2 +0 -0
- package/emoji/bdbd1627-c81d-42d9-b3f5-8979e2ab74dc +0 -0
- package/emoji/c257388f-8b85-450b-b168-ebdf8d8c3026 +0 -0
- package/emoji/c573fde1-faa9-4c1d-a172-e283645afcfd +0 -0
- package/emoji/c8e27810-e8ea-47ce-b7ec-cef0a6becb28 +0 -0
- package/emoji/cdeef182-b220-4850-9ecf-5d7c472fd754 +0 -0
- package/emoji/d59c1aa9-6f81-4c07-96dd-9953401ff211 +0 -0
- package/emoji/dd407dbf-a077-40ba-957f-337b3c5efdc7 +0 -0
- package/emoji/e01f6e06-5728-4e2c-90fb-314a5827b766 +0 -0
- package/emoji/e1f9ed12-a2ce-433d-b454-b833438a1f9c +0 -0
- package/emoji/e697e5c4-acd6-41cd-a43e-edee8da3ab7b +0 -0
- package/emoji/e8182220-3464-4e31-8c08-466baead7bfc +0 -0
- package/emoji/eb0f3fd5-abc9-4abf-b816-d8458aeb7ec8 +0 -0
- package/emoji/eb38ecf7-0d13-4c51-b96a-3777f79321c4 +0 -0
- package/emoji/ee515c85-b7ce-4493-a427-994cc0af0d59 +0 -0
- package/emoji/f485cef2-d3fa-4d59-88af-b79a3105cacf +0 -0
- package/emoji/ff0dab2a-7015-4e8c-b0d0-3569058359dc +0 -0
- package/files/01087968-07b6-4fdb-9aeb-fa9dc061be94 +0 -0
- package/files/030455b3-17cc-415f-b3b3-2bb56c92ee8b +0 -0
- package/files/06129f52-a858-4031-ad85-4d7f2bb793af +0 -0
- package/files/0a6155a9-069f-45e9-8a06-56992fe55187 +0 -0
- package/files/12b9dda5-feb1-4f20-a987-ad422db5ba73 +0 -0
- package/files/13c8fa1e-8821-4628-b607-9e4fa4510df7 +0 -0
- package/files/159b0ad3-1a30-419d-af22-28a1096ce825 +0 -0
- package/files/1699c563-6769-431b-8041-99a6a4386f25 +0 -0
- package/files/176b916c-0dd9-4b93-bfdc-b8ccabf15a96 +0 -0
- package/files/1a27dad9-8cc9-4a0c-9d4e-7bde63adda60 +0 -0
- package/files/1d29832c-059a-4190-bca6-b83ac77540d9 +0 -0
- package/files/1dcde013-8833-4369-8726-81236e4eb30e +0 -0
- package/files/2080fc9f-d2c4-4fbc-af84-232fe4900a4f +0 -0
- package/files/20889623-6869-46a2-999b-c07708c12521 +0 -0
- package/files/2107e243-c378-418d-a183-7df13873c65b +0 -0
- package/files/225ed61e-8f8c-4d17-b675-9a9f9918d5b4 +0 -0
- package/files/29ffba15-5acc-4ef8-b5a4-5ce61d3f6e85 +0 -0
- package/files/2a69434c-1d8a-4e9d-90d4-569aaeaae7e3 +0 -0
- package/files/2aec8fcf-25bf-478e-b2f6-fe67ad753071 +0 -0
- package/files/2c64490e-c7de-4cb2-b22e-70e2ac69d88f +0 -0
- package/files/2d80b4f4-6389-4f5d-8d32-f0ed93820907 +0 -0
- package/files/2f9dd26e-363f-4445-8ee5-28548007a33a +0 -0
- package/files/360dd8f5-76c4-4e86-ba22-9025dc7ca2a4 +0 -0
- package/files/3a0a7f5b-45a5-4340-ba7c-6a0fa2c63871 +0 -0
- package/files/3a8fa6be-8acf-4b7b-b653-25edc6b28cdc +0 -5
- package/files/3d9e191b-2c15-42aa-9928-c2cdbb5e14ca +0 -0
- package/files/3dd0f2ef-0d4e-4837-bffc-22aa645cbe85 +0 -0
- package/files/402c4b9b-adbf-4ab6-a21d-c17369b48abf +0 -0
- package/files/4685d988-33eb-4902-872f-3b824f497c8b +0 -0
- package/files/495f6c55-07f8-4713-a444-a2261a789b94 +0 -0
- package/files/4acba8e3-567b-4062-b81b-340e205a01de +0 -0
- package/files/4c0a10cc-395b-474a-8140-677ed607da89 +0 -0
- package/files/537584d9-25ad-4830-808a-a1e3d63e2a52 +0 -0
- package/files/5519f10d-745e-4474-8ca8-6c111693704c +0 -0
- package/files/5563cf92-a5e3-4be3-867d-9647a02298d4 +0 -0
- package/files/55b70d9b-fd58-4800-832c-d6b4521ba6d0 +0 -0
- package/files/5623cff3-ce9b-403b-9915-50d2bcbc981c +0 -0
- package/files/576314ba-2a1a-4753-8d77-2a9e04f509d1 +0 -0
- package/files/58ed97ae-0eac-4e04-add1-76646effa2d5 +0 -0
- package/files/68638efd-5389-481d-a841-36164c62c078 +0 -0
- package/files/6936d34b-a1f8-4a9d-b005-5544dbdcf5e5 +0 -0
- package/files/6bda9e88-3a28-47f7-994e-900ede6bf984 +0 -0
- package/files/7024a3b9-a863-4618-9dbd-fa6502017ae0 +0 -0
- package/files/707caccb-3780-456d-9056-c20bfdfc0e5b +0 -0
- package/files/74b8c73c-032b-4640-a5b3-d30dd270cbcb +0 -0
- package/files/767cb262-2974-40f8-8182-f7770b431923 +0 -0
- package/files/7a669935-cb48-4349-b26b-7705f8a04fbc +0 -0
- package/files/7bc8f678-6ee7-454c-a1f8-bb9358b89c95 +0 -0
- package/files/7ccc2699-07ec-4177-a9ce-ee7dc952fda1 +0 -0
- package/files/7e0eabf4-b334-4683-8156-ab8d949a0532 +0 -0
- package/files/7f0644ab-02a3-4121-adfc-29a7c55cf804 +0 -0
- package/files/7ff3266d-f103-48ce-90dd-95b9dfe5fcc9 +0 -0
- package/files/836e2e8e-aefd-4b4a-a9b9-bf7436158a8c +0 -0
- package/files/875f7ae5-fa23-4fc5-b04a-8433a7f7089c +0 -0
- package/files/8ca62da9-f204-49e3-b418-9451661b2904 +0 -0
- package/files/9283054c-107f-474d-b61e-f1d0061bcb86 +0 -0
- package/files/93b1ce7f-9566-452b-b4be-30f87d3de150 +0 -0
- package/files/93fb51c7-e19b-4ac1-9dc3-aeb8da0672ed +0 -0
- package/files/9b54a3a1-534a-4ed5-b016-3c74ed4c9edd +0 -0
- package/files/9b5beb6f-712d-4969-b127-fd66c9b2a9c6 +0 -0
- package/files/9e964fbf-d063-498e-b2e3-79f8d6afcf5f +0 -0
- package/files/a66b7a9f-58c2-47a8-a429-a6f0647c6fe9 +0 -0
- package/files/a7cbda7d-81ba-40f7-a997-51146af63e5f +0 -0
- package/files/ac01e83a-e572-41b6-81ab-c992cff7c170 +0 -0
- package/files/ad11a58f-f963-4233-bd29-1658b6b7e600 +0 -0
- package/files/ae60a4a8-08b9-4521-a0a3-d015a8b3ed08 +0 -0
- package/files/b1d3cf27-8d76-4cf9-aa51-d7c6bfd1b3bf +0 -0
- package/files/b2c68863-8554-4ac6-8e99-821f0267cf91 +0 -0
- package/files/b3043e01-a771-44af-bf19-5327646ff929 +0 -0
- package/files/b6d01b89-def5-4c7c-8e97-a3d88617f8f4 +0 -0
- package/files/b8760b32-bb1e-4cd7-a9d6-29c6e0b071bc +0 -0
- package/files/ba5e0470-44f7-47b3-bcb5-eeab3ca8c292 +0 -0
- package/files/c3969b5f-43ae-43f3-9bdd-d3959c79ca01 +0 -0
- package/files/caecd488-dbe6-4a30-a400-bced2ba8dae6 +0 -0
- package/files/d7d865b8-3a05-4ed1-b95d-b93dc1ebb9a9 +0 -0
- package/files/dbca5f31-cf38-4c1e-83b0-5ec8473196fc +0 -0
- package/files/dbf07e82-fff6-4985-bebe-d62c0458bfd0 +0 -0
- package/files/dd759c20-eead-4e57-9e74-4d3a2b978e91 +0 -5
- package/files/de0b2cf1-981b-4e4a-a04e-ac185d1620cd +0 -0
- package/files/de6837fe-5aa2-4ff9-a067-2646a008c780 +0 -0
- package/files/e2fde852-91cb-4e01-88a2-ee086b5f227c +0 -0
- package/files/e391d1ce-8d39-460c-a462-791730131f7f +0 -0
- package/files/e6d9b60f-2b1b-4c6f-ba6d-02f6de90d40f +0 -0
- package/files/f693c62d-2ac8-49fd-aa38-30904e013e3c +0 -0
- package/files/f749fdf5-193e-41f7-b643-5696f67c6402 +0 -0
- package/files/f8910384-e75c-4d65-825f-52a6748f6475 +0 -0
- package/files/fad8826b-e952-4acb-a509-3e6543b94d61 +0 -0
- package/jest.config.js +0 -13
- 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
|
+
}
|