@vex-chat/spire 0.7.4 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -26
- package/dist/ClientManager.d.ts +24 -25
- package/dist/ClientManager.js +232 -509
- package/dist/ClientManager.js.map +1 -0
- package/dist/Database.d.ts +49 -41
- package/dist/Database.js +698 -716
- package/dist/Database.js.map +1 -0
- package/dist/Spire.d.ts +23 -15
- package/dist/Spire.js +518 -218
- package/dist/Spire.js.map +1 -0
- package/dist/__tests__/Database.spec.js +113 -73
- 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 +85 -67
- 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 +101 -61
- package/dist/server/file.js.map +1 -0
- package/dist/server/index.d.ts +9 -6
- package/dist/server/index.js +595 -70
- package/dist/server/index.js.map +1 -0
- package/dist/server/invite.d.ts +4 -5
- package/dist/server/invite.js +21 -103
- 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 +66 -76
- 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 -63
- 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/169d76cb-6e7c-4e24-8224-017673eed8ff +0 -0
- package/avatars/1cae8d0b-0c6b-4c73-b25c-2d2349a57122 +0 -0
- package/avatars/1d87bc2b-71fc-4818-8004-40d04093e5fc +0 -0
- package/avatars/1f2c3d62-8b4d-465a-9895-51a24caef00d +0 -0
- package/avatars/245ee7fc-1004-41ab-adab-a04c9ceb9d7a +0 -0
- package/avatars/2465c28c-bdaf-4fa2-b42a-d054f04dc39b +0 -0
- package/avatars/3900a674-a2dd-4996-a61d-8c29b3270f41 +0 -0
- package/avatars/3c3b9c77-ea50-45e7-bb25-65d6f3d2a219 +0 -0
- package/avatars/414a2ff4-ad2f-4a7d-aa27-3b09ad3522b2 +0 -0
- package/avatars/522fe504-f0ad-4ed4-9dc6-e5dc2338e531 +0 -0
- package/avatars/53e5eb29-e7d1-4d58-add9-d44f39f0cfb7 +0 -0
- package/avatars/54d3f757-1038-41c8-bfb9-efd37b6e8ebe +0 -0
- package/avatars/623e86d7-c49c-46f6-9b76-ca70c9dbc43b +0 -0
- package/avatars/66e2abae-60f5-4dd1-b9fb-297a4bedfeb0 +0 -0
- package/avatars/6f37980e-f6fa-4d6d-9206-24050b403f45 +0 -0
- package/avatars/80138ece-eb5c-4b20-817b-903d6b0f54ae +0 -0
- package/avatars/841c77d3-37c4-431b-be22-672888062874 +0 -0
- package/avatars/88051a61-2bda-4750-95a3-5fb0b4918149 +0 -0
- package/avatars/89540973-c421-4bf1-8b89-9f1eaa51c1b5 +0 -0
- package/avatars/8a802fad-8c99-4942-8f80-47cec600149c +0 -0
- package/avatars/90531d8a-907a-4a1a-ac45-c85e4acb0df9 +0 -0
- package/avatars/9b7d0da9-b8d6-4801-b128-9993f79f464a +0 -0
- package/avatars/9bc456f1-c4c4-48a1-b9e6-fd44dd744a72 +0 -0
- package/avatars/9cf878bf-7430-49ec-a47a-78f3c93793dd +0 -0
- package/avatars/9ee82847-6ad3-45e5-92b9-f474a6c54d96 +0 -0
- package/avatars/ab44c857-d81d-4c88-85db-32f9532e5376 +0 -0
- package/avatars/b396a8d2-dc14-48d2-aac4-3755dc637051 +0 -0
- package/avatars/b6ac11c5-a8b2-4e0a-995a-9c87f1e58787 +0 -0
- package/avatars/b79d6855-b738-434c-be32-809637e62b9b +0 -0
- package/avatars/bbcd0188-d6a5-48ae-90fb-be5ff30599ab +0 -0
- package/avatars/bc7a9e0e-4720-4a6e-a90d-c11fec94d380 +0 -0
- package/avatars/c1c4889f-8383-4041-8bdd-9fded4046f37 +0 -0
- package/avatars/c4c7203c-d93a-4749-ade2-17053acf1d2a +0 -0
- package/avatars/ca974a70-0a23-4668-8b80-c4304dc7f793 +0 -0
- package/avatars/cf119a0d-eb3f-4bed-905b-f14a876c3535 +0 -0
- package/avatars/d464b03d-30c2-49e3-a666-80aefa8a1b35 +0 -0
- package/avatars/da0eee89-82f0-4d45-ab48-7d2786b634c5 +0 -0
- package/avatars/de4c77a5-68e9-4bb2-b40d-d964bf377d61 +0 -0
- package/avatars/dea95395-7d0b-42aa-a9ed-40c7d4fb4c48 +0 -0
- package/avatars/edb30749-59ba-4aa2-9c52-0fb22048f4cf +0 -0
- package/avatars/f17c245a-af7d-445b-9365-49f7f54b1eeb +0 -0
- package/avatars/f1ee6a35-b262-4dbf-99f5-3d011e3b98ec +0 -0
- package/avatars/f802bdd0-345d-41f6-9184-0f30e1258fb3 +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/files/00ea7368-45a7-4f6a-a199-974da14be1a0 +0 -0
- package/files/039503d2-a170-4962-b921-c97994ba64ff +0 -0
- package/files/14b6fa02-4cbb-40df-be4f-a07187cb619e +0 -0
- package/files/15c04cb1-dc6a-4f19-aa6f-4a3b92d05bf7 +0 -0
- package/files/2a8d411c-8b92-4532-b84d-d64c638d6293 +0 -0
- package/files/37de2cd3-08a8-4044-9c37-e13386765f3d +0 -0
- package/files/42452029-284e-4c81-9f18-feb6ce309eed +0 -0
- package/files/43d09f2f-29c8-415f-8c4a-23f2a32eb79e +0 -0
- package/files/52992923-33ab-44a1-8118-605e9b4856a7 +0 -0
- package/files/53180681-36e2-49c0-8382-94dca0da09bf +0 -0
- package/files/5a56cd7b-1d04-4619-b60f-1e5515b9164a +0 -0
- package/files/5ced7676-20c4-4219-a4f2-70a25eb7eea8 +0 -0
- package/files/60e787ff-8ec5-444d-b963-0aaf5313b53e +0 -0
- package/files/67a17729-fcb7-4339-9499-1fc08fea72ca +0 -0
- package/files/68d09565-908d-4a67-8f09-e183f8708eb4 +0 -0
- package/files/70e587c5-56e8-47f0-bd36-691efcb0cc2e +0 -4
- package/files/7a227619-b715-4e2c-a79d-b2158d56a799 +0 -0
- package/files/7e3cc3ea-b706-4835-994d-65d33acaf369 +0 -0
- package/files/816f72a0-65dc-40c1-8862-1d22065cca3c +0 -0
- package/files/8bf84972-5086-4631-a752-093d7b1a098b +0 -0
- package/files/8c46e3bc-3f2e-441d-b8cc-17428fc3d219 +0 -0
- package/files/8eb83364-8826-4eee-895a-ba5cd3ab85e9 +0 -0
- package/files/8faefaea-14e3-49e4-ac74-9710622bfae9 +0 -0
- package/files/91af08c2-9dca-41f4-b6c6-b7bf33505df9 +0 -0
- package/files/9349dffa-35dd-49a0-a651-10b438038f95 +0 -0
- package/files/b0a12a41-6283-4f27-b2c8-66774991d96c +0 -0
- package/files/b28afb08-d18b-48a8-93c3-6a59c66d8fee +0 -0
- package/files/be60fe01-1578-4363-a908-41500092b77d +0 -0
- package/files/cf7b90e3-734a-4453-9ff3-9a331404b5ef +0 -0
- package/files/d1be30aa-8ff4-41c1-b360-f65922029047 +0 -0
- package/files/d2d31a57-a413-443c-9b97-fa9ca394ef0b +0 -0
- package/files/d357f223-b786-478d-a113-b53cb62acdab +0 -0
- package/files/d4a69ee0-05c4-4ff0-8753-624cdd0f24e9 +0 -0
- package/files/d57f411b-1874-4f00-a6b0-2f86de6b229d +0 -0
- package/files/d85aaa33-7f70-4a70-b3d2-cad667beb38c +0 -0
- package/files/db2fc236-dbe0-4d24-bfaf-884829fd090a +0 -0
- package/files/df2e20ec-6dcc-4402-94ee-7d1163f84197 +0 -0
- package/files/e0880ab5-6d49-4dc0-a5ec-0d3793a8a7b8 +0 -0
- package/files/e59ee9c6-5aea-48ea-b3d9-94a324dc2260 +0 -0
- package/files/e6d7aad6-4220-4ce7-85f8-89d0b1f0cc89 +0 -0
- package/files/f0bfc98c-3534-459d-931a-7c6e77bde153 +0 -0
- package/files/f8443740-050c-4714-9bf6-334783ae6ffd +0 -0
- package/files/fc405ae7-f9fb-455d-ac8c-0ca0d88d4115 +0 -0
- package/jest.config.js +0 -13
- package/spire.sqlite +0 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Database } from "../Database.ts";
|
|
2
|
+
import type { TokenScopes } from "@vex-chat/types";
|
|
3
|
+
import type winston from "winston";
|
|
4
|
+
|
|
5
|
+
import express from "express";
|
|
6
|
+
|
|
7
|
+
import { msgpack } from "../utils/msgpack.ts";
|
|
8
|
+
|
|
9
|
+
import { getParam, getUser } from "./utils.ts";
|
|
10
|
+
|
|
11
|
+
import { protect } from "./index.ts";
|
|
12
|
+
|
|
13
|
+
export const getInviteRouter = (
|
|
14
|
+
db: Database,
|
|
15
|
+
log: winston.Logger,
|
|
16
|
+
tokenValidator: (key: string, scope: TokenScopes) => boolean,
|
|
17
|
+
notify: (
|
|
18
|
+
userID: string,
|
|
19
|
+
event: string,
|
|
20
|
+
transmissionID: string,
|
|
21
|
+
data?: unknown,
|
|
22
|
+
deviceID?: string,
|
|
23
|
+
) => void,
|
|
24
|
+
) => {
|
|
25
|
+
const router = express.Router();
|
|
26
|
+
router.patch("/:inviteID", protect, async (req, res) => {
|
|
27
|
+
const userDetails = getUser(req);
|
|
28
|
+
|
|
29
|
+
const invite = await db.retrieveInvite(getParam(req, "inviteID"));
|
|
30
|
+
if (!invite) {
|
|
31
|
+
res.sendStatus(404);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (new Date(invite.expiration).getTime() < Date.now()) {
|
|
36
|
+
res.sendStatus(401);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const permission = await db.createPermission(
|
|
41
|
+
userDetails.userID,
|
|
42
|
+
"server",
|
|
43
|
+
invite.serverID,
|
|
44
|
+
0,
|
|
45
|
+
);
|
|
46
|
+
res.send(msgpack.encode(permission));
|
|
47
|
+
notify(
|
|
48
|
+
userDetails.userID,
|
|
49
|
+
"permission",
|
|
50
|
+
crypto.randomUUID(),
|
|
51
|
+
permission,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
router.get("/:inviteID", protect, async (req, res) => {
|
|
56
|
+
const invite = await db.retrieveInvite(getParam(req, "inviteID"));
|
|
57
|
+
if (!invite) {
|
|
58
|
+
res.sendStatus(404);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
res.send(msgpack.encode(invite));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return router;
|
|
65
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API documentation endpoints (development only).
|
|
3
|
+
*
|
|
4
|
+
* - GET /docs — Scalar OpenAPI viewer (REST API)
|
|
5
|
+
* - GET /async-docs — AsyncAPI web component viewer (WebSocket protocol)
|
|
6
|
+
* - GET /openapi.json — raw OpenAPI 3.1 spec
|
|
7
|
+
* - GET /asyncapi.json — raw AsyncAPI 3.0 spec
|
|
8
|
+
*
|
|
9
|
+
* Specs are generated at build time from Zod schemas in @vex-chat/types.
|
|
10
|
+
* The interactive viewers require unsafe-eval (AJV) and CDN scripts (Scalar),
|
|
11
|
+
* so they are disabled in production. Raw JSON specs are always available.
|
|
12
|
+
*/
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
import express from "express";
|
|
17
|
+
|
|
18
|
+
import asyncApiSpec from "@vex-chat/types/asyncapi.json" with { type: "json" };
|
|
19
|
+
import openApiSpec from "@vex-chat/types/openapi.json" with { type: "json" };
|
|
20
|
+
|
|
21
|
+
import { apiReference } from "@scalar/express-api-reference";
|
|
22
|
+
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const isProduction = process.env["NODE_ENV"] === "production";
|
|
25
|
+
|
|
26
|
+
const pkgDir = (pkg: string) =>
|
|
27
|
+
path.resolve(__dirname, "../../node_modules", pkg);
|
|
28
|
+
|
|
29
|
+
export const setupDocs = (api: express.Application) => {
|
|
30
|
+
// Raw JSON specs — always available (no CSP issues, machine-readable)
|
|
31
|
+
api.get("/openapi.json", (_req, res) => {
|
|
32
|
+
res.json(openApiSpec);
|
|
33
|
+
});
|
|
34
|
+
api.get("/asyncapi.json", (_req, res) => {
|
|
35
|
+
res.json(asyncApiSpec);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (isProduction) return;
|
|
39
|
+
|
|
40
|
+
// Interactive viewers — development only (require unsafe-eval + CDN)
|
|
41
|
+
api.use("/docs", apiReference({ theme: "purple", url: "/openapi.json" }));
|
|
42
|
+
api.use("/vendor", express.static(pkgDir("@asyncapi/web-component/lib")));
|
|
43
|
+
api.use(
|
|
44
|
+
"/assets",
|
|
45
|
+
express.static(pkgDir("@asyncapi/react-component/styles")),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
api.get("/async-docs", (_req, res) => {
|
|
49
|
+
res.sendFile(path.resolve(__dirname, "../../public/async-docs.html"));
|
|
50
|
+
});
|
|
51
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Permission } from "@vex-chat/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check whether any permission in the list covers the given resource
|
|
5
|
+
* (any power level).
|
|
6
|
+
*/
|
|
7
|
+
export function hasAnyPermission(
|
|
8
|
+
permissions: Permission[],
|
|
9
|
+
resourceID: string,
|
|
10
|
+
): boolean {
|
|
11
|
+
return permissions.some((p) => p.resourceID === resourceID);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check whether any permission in the list grants at least `minPowerLevel`
|
|
16
|
+
* on the given resource.
|
|
17
|
+
*/
|
|
18
|
+
export function hasPermission(
|
|
19
|
+
permissions: Permission[],
|
|
20
|
+
resourceID: string,
|
|
21
|
+
minPowerLevel: number,
|
|
22
|
+
): boolean {
|
|
23
|
+
return permissions.some(
|
|
24
|
+
(p) => p.resourceID === resourceID && p.powerLevel > minPowerLevel,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check whether any permission in the list grants at least `minPowerLevel`
|
|
30
|
+
* on the given resource AND belongs to the specified user.
|
|
31
|
+
*/
|
|
32
|
+
export function userHasPermission(
|
|
33
|
+
permissions: Permission[],
|
|
34
|
+
userID: string,
|
|
35
|
+
minPowerLevel: number,
|
|
36
|
+
): boolean {
|
|
37
|
+
return permissions.some(
|
|
38
|
+
(p) => p.userID === userID && p.powerLevel > minPowerLevel,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiting middleware.
|
|
3
|
+
*
|
|
4
|
+
* Three tiers matching CWE-307 (brute-force auth), CWE-400 / CWE-770
|
|
5
|
+
* (unrestricted resource consumption), and OWASP API4:2023:
|
|
6
|
+
*
|
|
7
|
+
* - `globalLimiter` — baseline per-IP limit across every route. Wide
|
|
8
|
+
* enough to not bother normal clients, tight enough to shield the
|
|
9
|
+
* server from a single-host flood.
|
|
10
|
+
* - `authLimiter` — strict per-IP limit on auth endpoints (register,
|
|
11
|
+
* login, device challenge). `skipSuccessfulRequests` means only the
|
|
12
|
+
* failed attempts count, so a correct login doesn't eat the budget.
|
|
13
|
+
* - `uploadLimiter` — upload-specific limit applied before multer,
|
|
14
|
+
* so multer never even parses a request that's over quota.
|
|
15
|
+
*
|
|
16
|
+
* All three use `ipKeyGenerator` from `express-rate-limit@7.4+` to
|
|
17
|
+
* bucket IPv4 and IPv4-mapped IPv6 correctly (CVE-2026-30827 — older
|
|
18
|
+
* versions silently collapsed all IPv4-mapped IPv6 addresses into
|
|
19
|
+
* one bucket, which let attackers bypass the limiter).
|
|
20
|
+
*
|
|
21
|
+
* `trust proxy` must be set on the Express app (see Spire.ts) so
|
|
22
|
+
* `req.ip` returns the real client address, not the immediate proxy.
|
|
23
|
+
*/
|
|
24
|
+
import type { Request } from "express";
|
|
25
|
+
|
|
26
|
+
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Bucket requests by the real client IP, IPv6-safe.
|
|
30
|
+
*
|
|
31
|
+
* `req.ip` is already populated correctly because `trust proxy` is
|
|
32
|
+
* set to `1` in Spire's constructor. We still run it through
|
|
33
|
+
* `ipKeyGenerator` so IPv4-mapped IPv6 (`::ffff:1.2.3.4`) doesn't
|
|
34
|
+
* collide with unrelated IPv6 addresses in the same /56.
|
|
35
|
+
*/
|
|
36
|
+
const keyByIp = (req: Request): string => ipKeyGenerator(req.ip ?? "");
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Global per-IP limiter. Applied app-wide via `api.use(globalLimiter)`.
|
|
40
|
+
*
|
|
41
|
+
* 300 requests per 15 minutes per client IP. A human chatting via a
|
|
42
|
+
* browser or the libvex client won't come close; a single-host DoS
|
|
43
|
+
* gets throttled quickly.
|
|
44
|
+
*/
|
|
45
|
+
export const globalLimiter = rateLimit({
|
|
46
|
+
keyGenerator: keyByIp,
|
|
47
|
+
legacyHeaders: false,
|
|
48
|
+
limit: 300,
|
|
49
|
+
standardHeaders: "draft-7",
|
|
50
|
+
windowMs: 15 * 60 * 1000,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Strict auth endpoint limiter. Applied per-route to /auth, /register,
|
|
55
|
+
* and /auth/device.
|
|
56
|
+
*
|
|
57
|
+
* 5 failed attempts per 15 minutes per IP. Successful logins don't
|
|
58
|
+
* count (`skipSuccessfulRequests`), so a normal user doesn't lock
|
|
59
|
+
* themselves out by fat-fingering a password once. Blocks brute force
|
|
60
|
+
* (CWE-307) without harming UX.
|
|
61
|
+
*/
|
|
62
|
+
export const authLimiter = rateLimit({
|
|
63
|
+
keyGenerator: keyByIp,
|
|
64
|
+
legacyHeaders: false,
|
|
65
|
+
limit: 5,
|
|
66
|
+
skipSuccessfulRequests: true,
|
|
67
|
+
standardHeaders: "draft-7",
|
|
68
|
+
windowMs: 15 * 60 * 1000,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Upload endpoint limiter. Applied per-route to /file and /avatar
|
|
73
|
+
* POSTs, BEFORE multer parses the multipart body. Caps the number of
|
|
74
|
+
* upload attempts per minute so an attacker can't force spire to
|
|
75
|
+
* spend CPU/IO on repeated large-body parses.
|
|
76
|
+
*
|
|
77
|
+
* 20 uploads per minute per IP — generous for a chat client (rapid-
|
|
78
|
+
* fire image attachments) but tight enough to shield the disk.
|
|
79
|
+
*/
|
|
80
|
+
export const uploadLimiter = rateLimit({
|
|
81
|
+
keyGenerator: keyByIp,
|
|
82
|
+
legacyHeaders: false,
|
|
83
|
+
limit: 20,
|
|
84
|
+
standardHeaders: "draft-7",
|
|
85
|
+
windowMs: 60 * 1000,
|
|
86
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Database } from "../Database.ts";
|
|
2
|
+
import type winston from "winston";
|
|
3
|
+
|
|
4
|
+
import express from "express";
|
|
5
|
+
|
|
6
|
+
import { XUtils } from "@vex-chat/crypto";
|
|
7
|
+
import { xSignOpen } from "@vex-chat/crypto";
|
|
8
|
+
import { DevicePayloadSchema, TokenScopes } from "@vex-chat/types";
|
|
9
|
+
|
|
10
|
+
import { stringify } from "uuid";
|
|
11
|
+
|
|
12
|
+
import { msgpack } from "../utils/msgpack.ts";
|
|
13
|
+
|
|
14
|
+
import { censorUser, getParam, getUser } from "./utils.ts";
|
|
15
|
+
|
|
16
|
+
import { protect } from "./index.ts";
|
|
17
|
+
|
|
18
|
+
export const getUserRouter = (
|
|
19
|
+
db: Database,
|
|
20
|
+
log: winston.Logger,
|
|
21
|
+
tokenValidator: (key: string, scope: TokenScopes) => boolean,
|
|
22
|
+
) => {
|
|
23
|
+
const router = express.Router();
|
|
24
|
+
|
|
25
|
+
router.get("/:id", protect, async (req, res) => {
|
|
26
|
+
const user = await db.retrieveUser(getParam(req, "id"));
|
|
27
|
+
|
|
28
|
+
if (user) {
|
|
29
|
+
return res.send(msgpack.encode(censorUser(user)));
|
|
30
|
+
} else {
|
|
31
|
+
return res.sendStatus(404);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
router.get("/:id/devices", protect, async (req, res) => {
|
|
36
|
+
const deviceList = await db.retrieveUserDeviceList([
|
|
37
|
+
getParam(req, "id"),
|
|
38
|
+
]);
|
|
39
|
+
return res.send(msgpack.encode(deviceList));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
router.get("/:id/permissions", protect, async (req, res) => {
|
|
43
|
+
const userDetails = getUser(req);
|
|
44
|
+
const permissions = await db.retrievePermissions(
|
|
45
|
+
userDetails.userID,
|
|
46
|
+
"all",
|
|
47
|
+
);
|
|
48
|
+
res.send(msgpack.encode(permissions));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
router.get("/:id/servers", protect, async (req, res) => {
|
|
52
|
+
const userDetails = getUser(req);
|
|
53
|
+
const servers = await db.retrieveServers(userDetails.userID);
|
|
54
|
+
res.send(msgpack.encode(servers));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
router.delete("/:userID/devices/:deviceID", protect, async (req, res) => {
|
|
58
|
+
const device = await db.retrieveDevice(getParam(req, "deviceID"));
|
|
59
|
+
|
|
60
|
+
if (!device) {
|
|
61
|
+
res.sendStatus(404);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const userDetails = getUser(req);
|
|
65
|
+
if (userDetails.userID !== device.owner) {
|
|
66
|
+
res.sendStatus(401);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const deviceList = await db.retrieveUserDeviceList([
|
|
70
|
+
userDetails.userID,
|
|
71
|
+
]);
|
|
72
|
+
if (deviceList.length === 1) {
|
|
73
|
+
res.status(400).send({
|
|
74
|
+
error: "You can't delete your last device.",
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await db.deleteDevice(device.deviceID);
|
|
80
|
+
res.sendStatus(200);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
router.post("/:id/devices", protect, async (req, res) => {
|
|
84
|
+
const userDetails = getUser(req);
|
|
85
|
+
const parsed = DevicePayloadSchema.safeParse(req.body);
|
|
86
|
+
if (!parsed.success) {
|
|
87
|
+
res.status(400).json({
|
|
88
|
+
error: "Invalid device payload",
|
|
89
|
+
issues: parsed.error.issues,
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const deviceData = parsed.data;
|
|
94
|
+
|
|
95
|
+
const token = xSignOpen(
|
|
96
|
+
XUtils.decodeHex(deviceData.signed),
|
|
97
|
+
XUtils.decodeHex(deviceData.signKey),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (!token) {
|
|
101
|
+
log.warn("Invalid signature on token.");
|
|
102
|
+
res.sendStatus(400);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (tokenValidator(stringify(token), TokenScopes.Device)) {
|
|
107
|
+
try {
|
|
108
|
+
const device = await db.createDevice(
|
|
109
|
+
userDetails.userID,
|
|
110
|
+
deviceData,
|
|
111
|
+
);
|
|
112
|
+
res.send(msgpack.encode(device));
|
|
113
|
+
} catch (err: unknown) {
|
|
114
|
+
log.warn(String(err));
|
|
115
|
+
// failed registration due to signkey being taken
|
|
116
|
+
res.sendStatus(470);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
res.sendStatus(401);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return router;
|
|
125
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Device, User, UserRecord } from "@vex-chat/types";
|
|
2
|
+
import type { Request } from "express";
|
|
3
|
+
|
|
4
|
+
import { AppError } from "./errors.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Strips password fields from a DB user record, returning the
|
|
8
|
+
* public-safe User shape.
|
|
9
|
+
*/
|
|
10
|
+
export const censorUser = (user: UserRecord): User => {
|
|
11
|
+
return {
|
|
12
|
+
lastSeen: user.lastSeen,
|
|
13
|
+
userID: user.userID,
|
|
14
|
+
username: user.username,
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Safely extract the authenticated device from a request.
|
|
20
|
+
* Throws if the device is not set (i.e. checkDevice middleware did
|
|
21
|
+
* not run or the token was not a device token).
|
|
22
|
+
*
|
|
23
|
+
* Throws `AppError(401)` — the central error handler turns this into
|
|
24
|
+
* a JSON 401 response. No stack trace or raw Error leaks.
|
|
25
|
+
*/
|
|
26
|
+
export function getDevice(req: Request): Device {
|
|
27
|
+
if (!req.device) throw new AppError(401, "Not authenticated");
|
|
28
|
+
return req.device;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Safely extract a required route parameter.
|
|
33
|
+
*
|
|
34
|
+
* Throws `AppError(400)` if the parameter is missing or is an array
|
|
35
|
+
* (which shouldn't happen for well-wired routes, but defensively
|
|
36
|
+
* checked). The error message deliberately does NOT include the
|
|
37
|
+
* user-supplied value — CWE-79 / CWE-116 fix for the earlier
|
|
38
|
+
* `"Missing route parameter: " + name` concatenation pattern.
|
|
39
|
+
*/
|
|
40
|
+
export function getParam(req: Request, name: string): string {
|
|
41
|
+
const value = req.params[name];
|
|
42
|
+
if (!value || Array.isArray(value)) {
|
|
43
|
+
throw new AppError(400, "Missing or invalid route parameter");
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Safely extract the authenticated user from a request.
|
|
50
|
+
* Throws if the user is not set (i.e. protect middleware was not
|
|
51
|
+
* applied to this route).
|
|
52
|
+
*
|
|
53
|
+
* Throws `AppError(401)` — the central error handler turns this into
|
|
54
|
+
* a JSON 401 response.
|
|
55
|
+
*/
|
|
56
|
+
export function getUser(req: Request): User {
|
|
57
|
+
if (!req.user) throw new AppError(401, "Not authenticated");
|
|
58
|
+
return req.user;
|
|
59
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Device, User } from "@vex-chat/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Augment Express's global Request interface with the properties
|
|
5
|
+
* set by checkAuth and checkDevice middleware.
|
|
6
|
+
*
|
|
7
|
+
* The global Express.Request is merged into express-serve-static-core's
|
|
8
|
+
* Request via `extends Express.Request`, so properties added here are
|
|
9
|
+
* available on `req` in all route handlers.
|
|
10
|
+
*/
|
|
11
|
+
declare global {
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace -- required for Express declaration merging
|
|
13
|
+
namespace Express {
|
|
14
|
+
interface Request {
|
|
15
|
+
bearerToken?: string;
|
|
16
|
+
device?: Device;
|
|
17
|
+
exp?: number;
|
|
18
|
+
user?: User;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import winston from "winston";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @ignore
|
|
5
|
+
*/
|
|
6
|
+
export function createLogger(logName: string, logLevel?: string) {
|
|
7
|
+
const logger = winston.createLogger({
|
|
8
|
+
defaultMeta: { service: "vex-" + logName },
|
|
9
|
+
format: winston.format.combine(
|
|
10
|
+
winston.format.timestamp({
|
|
11
|
+
format: "YYYY-MM-DD HH:mm:ss",
|
|
12
|
+
}),
|
|
13
|
+
winston.format.errors({ stack: true }),
|
|
14
|
+
winston.format.splat(),
|
|
15
|
+
winston.format.json(),
|
|
16
|
+
),
|
|
17
|
+
level: logLevel || "error",
|
|
18
|
+
transports: [
|
|
19
|
+
//
|
|
20
|
+
// - Write all logs with level `error` and below to `error.log`
|
|
21
|
+
// - Write all logs with level `info` and below to `combined.log`
|
|
22
|
+
//
|
|
23
|
+
new winston.transports.File({
|
|
24
|
+
filename: "vex:" + logName + ".log",
|
|
25
|
+
level: "error",
|
|
26
|
+
}),
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
//
|
|
30
|
+
// If we're not in production then log to the `console` with the format:
|
|
31
|
+
// `${info.level}: ${info.message} JSON.stringify({ ...rest }) `
|
|
32
|
+
//
|
|
33
|
+
if (
|
|
34
|
+
process.env["NODE_ENV"] !== "production" &&
|
|
35
|
+
process.env["NODE_ENV"] !== "test"
|
|
36
|
+
) {
|
|
37
|
+
logger.add(
|
|
38
|
+
new winston.transports.Console({
|
|
39
|
+
format: winston.format.combine(
|
|
40
|
+
winston.format.colorize(),
|
|
41
|
+
winston.format.simple(),
|
|
42
|
+
),
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return logger;
|
|
47
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns the JWT signing secret.
|
|
3
|
+
*
|
|
4
|
+
* Prefers JWT_SECRET (dedicated HMAC key) over SPK (NaCl server signing key).
|
|
5
|
+
* Falls back to SPK for backward compat with existing deployments.
|
|
6
|
+
*/
|
|
7
|
+
export function getJwtSecret(): string {
|
|
8
|
+
const secret = process.env["JWT_SECRET"] ?? process.env["SPK"];
|
|
9
|
+
if (!secret) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
"Neither JWT_SECRET nor SPK is set. " +
|
|
12
|
+
"Set JWT_SECRET (preferred) or SPK in your environment.",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
return secret;
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { config } from "dotenv";
|
|
2
|
+
|
|
3
|
+
/* Populate process.env with vars from .env and verify required vars are present. */
|
|
4
|
+
export function loadEnv(): void {
|
|
5
|
+
config();
|
|
6
|
+
const requiredEnvVars: string[] = ["CANARY", "DB_TYPE", "SPK"];
|
|
7
|
+
for (const required of requiredEnvVars) {
|
|
8
|
+
if (process.env[required] === undefined) {
|
|
9
|
+
process.stderr.write(
|
|
10
|
+
`Required environment variable '${required}' is not set. Please consult the README.\n`,
|
|
11
|
+
);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|