@vex-chat/spire 4.0.0 → 4.1.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.
@@ -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
+ }
@@ -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 svg but received " +
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 svg but received " +
147
+ "Unsupported file type. Expected jpeg, png, gif, apng, avif, or webp but received " +
147
148
  String(mimeType?.ext),
148
149
  });
149
150
  return;
@@ -150,6 +150,13 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
150
150
  "use strict";
151
151
 
152
152
  var params = new URLSearchParams(window.location.hash.slice(1));
153
+ if (window.location.hash) {
154
+ window.history.replaceState(
155
+ null,
156
+ "",
157
+ window.location.pathname + window.location.search,
158
+ );
159
+ }
153
160
  var action = document.getElementById("action");
154
161
  var statusEl = document.getElementById("status");
155
162
  var titleEl = document.getElementById("title");
@@ -158,6 +165,7 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
158
165
  var startupError = null;
159
166
  var apiBase = window.location.origin;
160
167
  var busy = false;
168
+ var completed = false;
161
169
 
162
170
  try {
163
171
  apiBase = resolveTrustedApiBase(params.get("api"));
@@ -165,11 +173,16 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
165
173
  startupError = err;
166
174
  }
167
175
 
168
- if (mode === "register") {
176
+ if (mode === "register" || mode === "register-handoff") {
169
177
  titleEl.textContent = "Create a passkey for Vex.";
170
178
  copyEl.textContent = "This adds a passkey to your Vex account.";
171
179
  action.textContent = "Create passkey";
172
180
  setStatus("Ready to create passkey.");
181
+ } else if (mode === "authenticate-handoff") {
182
+ titleEl.textContent = "Continue signing in to Vex.";
183
+ copyEl.textContent =
184
+ "Use your passkey here, then return to the Vex desktop app.";
185
+ setStatus("Ready to verify passkey.");
173
186
  } else {
174
187
  setStatus("Ready to verify passkey.");
175
188
  }
@@ -190,7 +203,7 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
190
203
 
191
204
  function setBusy(nextBusy) {
192
205
  busy = nextBusy;
193
- action.disabled = nextBusy;
206
+ action.disabled = nextBusy || completed;
194
207
  }
195
208
 
196
209
  function apiUrl(path) {
@@ -271,12 +284,19 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
271
284
  }
272
285
  if (mode === "register") {
273
286
  await registerPasskey();
287
+ } else if (mode === "register-handoff") {
288
+ await registerPasskeyWithHandoff();
289
+ } else if (mode === "authenticate-handoff") {
290
+ await authenticatePasskeyWithHandoff();
274
291
  } else {
275
292
  await recoverWithPasskey();
276
293
  }
277
294
  } catch (err) {
278
295
  setStatus(errorMessage(err), "error");
279
- action.textContent = mode === "register" ? "Try again" : "Retry passkey";
296
+ action.textContent =
297
+ mode === "register" || mode === "register-handoff"
298
+ ? "Try again"
299
+ : "Retry passkey";
280
300
  } finally {
281
301
  setBusy(false);
282
302
  }
@@ -297,15 +317,8 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
297
317
  body: { name: name },
298
318
  token: token,
299
319
  });
300
- var credentialOptions = makeCreationOptions(begin.options);
301
-
302
320
  setStatus("Waiting for browser passkey prompt...");
303
- var credential = await navigator.credentials.create({
304
- publicKey: credentialOptions,
305
- });
306
- if (!credential) {
307
- throw new Error("No passkey was created.");
308
- }
321
+ var credential = await createPasskeyCredential(begin.options);
309
322
 
310
323
  setStatus("Saving passkey...");
311
324
  await apiRequest("/user/" + encodeURIComponent(userID) + "/passkeys/register/finish", {
@@ -319,6 +332,90 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
319
332
  setStatus("Passkey saved. Return to the Vex CLI.", "success");
320
333
  titleEl.textContent = "Passkey saved.";
321
334
  copyEl.textContent = "The CLI can finish connecting now.";
335
+ completed = true;
336
+ action.textContent = "Done";
337
+ }
338
+
339
+ async function registerPasskeyWithHandoff() {
340
+ var token = requiredParam("token");
341
+ var requestID = requiredParam("request");
342
+
343
+ setStatus("Requesting a one-time passkey challenge...");
344
+ var begin = await apiRequest(
345
+ "/auth/passkey/browser-registration/" +
346
+ encodeURIComponent(requestID) +
347
+ "/begin",
348
+ { body: { token: token } },
349
+ );
350
+
351
+ setStatus("Waiting for browser passkey prompt...");
352
+ var credential = await createPasskeyCredential(begin.options);
353
+
354
+ setStatus("Saving passkey...");
355
+ await apiRequest(
356
+ "/auth/passkey/browser-registration/" +
357
+ encodeURIComponent(requestID) +
358
+ "/finish",
359
+ {
360
+ body: {
361
+ response: registrationResponseJSON(credential),
362
+ token: token,
363
+ },
364
+ },
365
+ );
366
+ setStatus("Passkey saved. You can close this page.", "success");
367
+ titleEl.textContent = "Passkey saved.";
368
+ copyEl.textContent = "Vex has updated your account.";
369
+ completed = true;
370
+ action.textContent = "Done";
371
+ }
372
+
373
+ async function createPasskeyCredential(options) {
374
+ var credential = await navigator.credentials.create({
375
+ publicKey: makeCreationOptions(options),
376
+ });
377
+ if (!credential) {
378
+ throw new Error("No passkey was created.");
379
+ }
380
+ return credential;
381
+ }
382
+
383
+ async function authenticatePasskeyWithHandoff() {
384
+ var token = requiredParam("token");
385
+ var requestID = requiredParam("request");
386
+
387
+ setStatus("Requesting a one-time passkey challenge...");
388
+ var begin = await apiRequest(
389
+ "/auth/passkey/browser-authentication/" +
390
+ encodeURIComponent(requestID) +
391
+ "/begin",
392
+ { body: { token: token } },
393
+ );
394
+
395
+ setStatus("Waiting for browser passkey prompt...");
396
+ var credential = await navigator.credentials.get({
397
+ publicKey: makeRequestOptions(begin.options),
398
+ });
399
+ if (!credential) {
400
+ throw new Error("No passkey was returned.");
401
+ }
402
+
403
+ setStatus("Returning verification to Vex...");
404
+ await apiRequest(
405
+ "/auth/passkey/browser-authentication/" +
406
+ encodeURIComponent(requestID) +
407
+ "/finish",
408
+ {
409
+ body: {
410
+ response: authenticationResponseJSON(credential),
411
+ token: token,
412
+ },
413
+ },
414
+ );
415
+ setStatus("Passkey verified. You can close this page.", "success");
416
+ titleEl.textContent = "Passkey verified.";
417
+ copyEl.textContent = "Return to Vex to finish signing in.";
418
+ completed = true;
322
419
  action.textContent = "Done";
323
420
  }
324
421
 
@@ -371,6 +468,7 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
371
468
  setStatus("CLI device recovered. Return to the Vex CLI.", "success");
372
469
  titleEl.textContent = "Device signed in.";
373
470
  copyEl.textContent = "The CLI can finish connecting now.";
471
+ completed = true;
374
472
  action.textContent = "Done";
375
473
  }
376
474