@vex-chat/spire 4.0.0 → 4.1.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.
- package/dist/Database.d.ts +8 -0
- package/dist/Database.js +68 -0
- package/dist/Database.js.map +1 -1
- package/dist/Spire.js +1 -1
- package/dist/Spire.js.map +1 -1
- package/dist/server/avatar.js +3 -2
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/cliPasskeyPage.js +580 -91
- package/dist/server/cliPasskeyPage.js.map +1 -1
- package/dist/server/index.js +163 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +326 -3
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/serverIcon.d.ts +10 -0
- package/dist/server/serverIcon.js +158 -0
- package/dist/server/serverIcon.js.map +1 -0
- package/package.json +2 -2
- package/src/Database.ts +83 -0
- package/src/Spire.ts +1 -1
- package/src/__tests__/browserPasskeyAuthentication.spec.ts +192 -0
- package/src/__tests__/browserPasskeyRegistration.spec.ts +182 -0
- package/src/__tests__/cliPasskeyPage.spec.ts +17 -1
- package/src/__tests__/serverManagement.spec.ts +262 -0
- package/src/server/avatar.ts +3 -2
- package/src/server/cliPasskeyPage.ts +580 -91
- package/src/server/index.ts +211 -31
- package/src/server/passkey.ts +480 -3
- package/src/server/serverIcon.ts +199 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
Channel,
|
|
9
|
+
Permission,
|
|
10
|
+
User,
|
|
11
|
+
Server as VexServer,
|
|
12
|
+
} from "@vex-chat/types";
|
|
13
|
+
import type { Server } from "node:http";
|
|
14
|
+
|
|
15
|
+
import express from "express";
|
|
16
|
+
|
|
17
|
+
import { xSignKeyPair, XUtils } from "@vex-chat/crypto";
|
|
18
|
+
|
|
19
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
20
|
+
|
|
21
|
+
import { Database } from "../Database.ts";
|
|
22
|
+
import { initApp } from "../server/index.ts";
|
|
23
|
+
import { signAuthJwt } from "../utils/authJwt.ts";
|
|
24
|
+
import { msgpack } from "../utils/msgpack.ts";
|
|
25
|
+
|
|
26
|
+
const testImage = XUtils.decodeBase64(
|
|
27
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAABJRU5ErkJggg==",
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const originalJwtSecret = process.env["JWT_SECRET"];
|
|
31
|
+
const user: User = {
|
|
32
|
+
lastSeen: new Date(0).toISOString(),
|
|
33
|
+
userID: "8d768e5c-55ec-4fe4-bcb2-26bdde8076d9",
|
|
34
|
+
username: "server-admin",
|
|
35
|
+
};
|
|
36
|
+
const member: User = {
|
|
37
|
+
lastSeen: new Date(0).toISOString(),
|
|
38
|
+
userID: "3e1225e2-88bb-469f-bef3-28531da7f97a",
|
|
39
|
+
username: "group-member",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
describe("server management routes", () => {
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
if (originalJwtSecret === undefined) {
|
|
45
|
+
delete process.env["JWT_SECRET"];
|
|
46
|
+
} else {
|
|
47
|
+
process.env["JWT_SECRET"] = originalJwtSecret;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("renames servers and channels, manages icons, and preserves one channel", async () => {
|
|
52
|
+
process.env["JWT_SECRET"] = "server-management-test-secret";
|
|
53
|
+
const db = new Database({ dbType: "sqlite3mem" });
|
|
54
|
+
await ready(db);
|
|
55
|
+
await db["db"]
|
|
56
|
+
.insertInto("users")
|
|
57
|
+
.values(
|
|
58
|
+
[user, member].map((entry) => ({
|
|
59
|
+
...entry,
|
|
60
|
+
passwordHash: "not-used-by-this-test",
|
|
61
|
+
})),
|
|
62
|
+
)
|
|
63
|
+
.execute();
|
|
64
|
+
const created = await db.createServer("Before", user.userID);
|
|
65
|
+
const memberPermission = await db.createPermission(
|
|
66
|
+
member.userID,
|
|
67
|
+
"server",
|
|
68
|
+
created.serverID,
|
|
69
|
+
0,
|
|
70
|
+
);
|
|
71
|
+
const ownerPermission = (
|
|
72
|
+
await db.retrievePermissionsByResourceID(created.serverID)
|
|
73
|
+
).find((permission) => permission.userID === user.userID);
|
|
74
|
+
expect(ownerPermission).toBeDefined();
|
|
75
|
+
const notify = vi.fn();
|
|
76
|
+
const app = express();
|
|
77
|
+
initApp(app, db, () => false, xSignKeyPair(), notify);
|
|
78
|
+
const listener = await listen(app);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const origin = serverOrigin(listener);
|
|
82
|
+
const headers = {
|
|
83
|
+
Authorization: `Bearer ${signAuthJwt({ scope: "user", user }, "5m")}`,
|
|
84
|
+
"Content-Type": "application/msgpack",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const createServer = await fetch(`${origin}/servers`, {
|
|
88
|
+
body: msgpack.encode({ name: "Café 作戦" }),
|
|
89
|
+
headers,
|
|
90
|
+
method: "POST",
|
|
91
|
+
});
|
|
92
|
+
expect(createServer.status).toBe(200);
|
|
93
|
+
expect(await decode<VexServer>(createServer)).toMatchObject({
|
|
94
|
+
name: "Café 作戦",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const renameServer = await fetch(
|
|
98
|
+
`${origin}/server/${created.serverID}`,
|
|
99
|
+
{
|
|
100
|
+
body: msgpack.encode({ name: "After" }),
|
|
101
|
+
headers,
|
|
102
|
+
method: "PATCH",
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
expect(renameServer.status).toBe(200);
|
|
106
|
+
expect(await decode<VexServer>(renameServer)).toMatchObject({
|
|
107
|
+
name: "After",
|
|
108
|
+
serverID: created.serverID,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const promoteMember = await fetch(
|
|
112
|
+
`${origin}/permission/${memberPermission.permissionID}`,
|
|
113
|
+
{
|
|
114
|
+
body: msgpack.encode({ powerLevel: 50 }),
|
|
115
|
+
headers,
|
|
116
|
+
method: "PATCH",
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
expect(promoteMember.status).toBe(200);
|
|
120
|
+
expect(await decode<Permission>(promoteMember)).toMatchObject({
|
|
121
|
+
permissionID: memberPermission.permissionID,
|
|
122
|
+
powerLevel: 50,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const memberHeaders = {
|
|
126
|
+
Authorization: `Bearer ${signAuthJwt({ scope: "user", user: member }, "5m")}`,
|
|
127
|
+
"Content-Type": "application/msgpack",
|
|
128
|
+
};
|
|
129
|
+
const moderatorCannotChangeRoles = await fetch(
|
|
130
|
+
`${origin}/permission/${ownerPermission!.permissionID}`,
|
|
131
|
+
{
|
|
132
|
+
body: msgpack.encode({ powerLevel: 100 }),
|
|
133
|
+
headers: memberHeaders,
|
|
134
|
+
method: "PATCH",
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
expect(moderatorCannotChangeRoles.status).toBe(403);
|
|
138
|
+
|
|
139
|
+
const ownerCannotChangeOwnRole = await fetch(
|
|
140
|
+
`${origin}/permission/${ownerPermission!.permissionID}`,
|
|
141
|
+
{
|
|
142
|
+
body: msgpack.encode({ powerLevel: 50 }),
|
|
143
|
+
headers,
|
|
144
|
+
method: "PATCH",
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
expect(ownerCannotChangeOwnRole.status).toBe(400);
|
|
148
|
+
|
|
149
|
+
const unsupportedRole = await fetch(
|
|
150
|
+
`${origin}/permission/${memberPermission.permissionID}`,
|
|
151
|
+
{
|
|
152
|
+
body: msgpack.encode({ powerLevel: 25 }),
|
|
153
|
+
headers,
|
|
154
|
+
method: "PATCH",
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
expect(unsupportedRole.status).toBe(400);
|
|
158
|
+
|
|
159
|
+
const [general] = await db.retrieveChannels(created.serverID);
|
|
160
|
+
expect(general).toBeDefined();
|
|
161
|
+
const renameChannel = await fetch(
|
|
162
|
+
`${origin}/channel/${general!.channelID}`,
|
|
163
|
+
{
|
|
164
|
+
body: msgpack.encode({ name: "announcements" }),
|
|
165
|
+
headers,
|
|
166
|
+
method: "PATCH",
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
expect(renameChannel.status).toBe(200);
|
|
170
|
+
expect(await decode<Channel>(renameChannel)).toMatchObject({
|
|
171
|
+
name: "announcements",
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const lastChannel = await fetch(
|
|
175
|
+
`${origin}/channel/${general!.channelID}`,
|
|
176
|
+
{ headers, method: "DELETE" },
|
|
177
|
+
);
|
|
178
|
+
expect(lastChannel.status).toBe(409);
|
|
179
|
+
|
|
180
|
+
const extra = await db.createChannel("extra", created.serverID);
|
|
181
|
+
const deleteExtra = await fetch(
|
|
182
|
+
`${origin}/channel/${extra.channelID}`,
|
|
183
|
+
{ headers, method: "DELETE" },
|
|
184
|
+
);
|
|
185
|
+
expect(deleteExtra.status).toBe(200);
|
|
186
|
+
|
|
187
|
+
const setIcon = await fetch(
|
|
188
|
+
`${origin}/server-icon/${created.serverID}/json`,
|
|
189
|
+
{
|
|
190
|
+
body: msgpack.encode({
|
|
191
|
+
file: XUtils.encodeBase64(testImage),
|
|
192
|
+
}),
|
|
193
|
+
headers,
|
|
194
|
+
method: "POST",
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
expect(setIcon.status).toBe(200);
|
|
198
|
+
const withIcon = await decode<VexServer>(setIcon);
|
|
199
|
+
expect(withIcon.icon).toEqual(expect.any(String));
|
|
200
|
+
|
|
201
|
+
const iconResponse = await fetch(
|
|
202
|
+
`${origin}/server-icon/${withIcon.icon!}`,
|
|
203
|
+
);
|
|
204
|
+
expect(iconResponse.status).toBe(200);
|
|
205
|
+
expect(iconResponse.headers.get("cache-control")).toContain(
|
|
206
|
+
"immutable",
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const removeIcon = await fetch(
|
|
210
|
+
`${origin}/server-icon/${created.serverID}`,
|
|
211
|
+
{ headers, method: "DELETE" },
|
|
212
|
+
);
|
|
213
|
+
expect(removeIcon.status).toBe(200);
|
|
214
|
+
expect((await decode<VexServer>(removeIcon)).icon).toBeUndefined();
|
|
215
|
+
expect(notify).toHaveBeenCalledWith(
|
|
216
|
+
user.userID,
|
|
217
|
+
"serverChange",
|
|
218
|
+
expect.any(String),
|
|
219
|
+
created.serverID,
|
|
220
|
+
);
|
|
221
|
+
} finally {
|
|
222
|
+
await close(listener);
|
|
223
|
+
await db.close();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
async function close(server: Server): Promise<void> {
|
|
229
|
+
await new Promise<void>((resolve, reject) => {
|
|
230
|
+
server.close((error) => {
|
|
231
|
+
if (error) reject(error);
|
|
232
|
+
else resolve();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function decode<T>(response: Response): Promise<T> {
|
|
238
|
+
return msgpack.decode(new Uint8Array(await response.arrayBuffer())) as T;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function listen(app: express.Application): Promise<Server> {
|
|
242
|
+
return new Promise((resolve) => {
|
|
243
|
+
const server = app.listen(0, "127.0.0.1", () => {
|
|
244
|
+
resolve(server);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function ready(db: Database): Promise<void> {
|
|
250
|
+
await new Promise<void>((resolve, reject) => {
|
|
251
|
+
db.once("ready", resolve);
|
|
252
|
+
db.once("error", reject);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function serverOrigin(server: Server): string {
|
|
257
|
+
const address = server.address();
|
|
258
|
+
if (!address || typeof address === "string") {
|
|
259
|
+
throw new Error("Expected TCP listener.");
|
|
260
|
+
}
|
|
261
|
+
return `http://127.0.0.1:${String(address.port)}`;
|
|
262
|
+
}
|
package/src/server/avatar.ts
CHANGED
|
@@ -54,6 +54,7 @@ export const getAvatarRouter = () => {
|
|
|
54
54
|
}
|
|
55
55
|
res.set("Content-type", typeDetails.mime);
|
|
56
56
|
res.set("Cache-control", "public, max-age=31536000");
|
|
57
|
+
res.set("Cross-Origin-Resource-Policy", "cross-origin");
|
|
57
58
|
|
|
58
59
|
const stream = fs.createReadStream(filePath);
|
|
59
60
|
stream.on("error", (_err) => {
|
|
@@ -100,7 +101,7 @@ export const getAvatarRouter = () => {
|
|
|
100
101
|
if (!ALLOWED_IMAGE_TYPES.includes(mimeType?.mime || "no/type")) {
|
|
101
102
|
res.status(400).send({
|
|
102
103
|
error:
|
|
103
|
-
"Unsupported file type. Expected jpeg, png, gif, apng, avif, or
|
|
104
|
+
"Unsupported file type. Expected jpeg, png, gif, apng, avif, or webp but received " +
|
|
104
105
|
String(mimeType?.ext),
|
|
105
106
|
});
|
|
106
107
|
return;
|
|
@@ -143,7 +144,7 @@ export const getAvatarRouter = () => {
|
|
|
143
144
|
if (!ALLOWED_IMAGE_TYPES.includes(mimeType?.mime || "no/type")) {
|
|
144
145
|
res.status(400).send({
|
|
145
146
|
error:
|
|
146
|
-
"Unsupported file type. Expected jpeg, png, gif, apng, avif, or
|
|
147
|
+
"Unsupported file type. Expected jpeg, png, gif, apng, avif, or webp but received " +
|
|
147
148
|
String(mimeType?.ext),
|
|
148
149
|
});
|
|
149
150
|
return;
|