@vex-chat/spire 3.0.1 → 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.
- package/README.md +9 -8
- package/dist/Database.d.ts +20 -11
- package/dist/Database.js +229 -118
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +4 -2
- package/dist/Spire.js +155 -72
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +0 -2
- package/dist/migrations/2026-04-06_initial-schema.js +4 -5
- package/dist/migrations/2026-04-06_initial-schema.js.map +1 -1
- package/dist/migrations/{2026-04-14_argon2id-password-hashing.d.ts → 2026-07-14_query-indexes.d.ts} +1 -1
- package/dist/migrations/2026-07-14_query-indexes.js +31 -0
- package/dist/migrations/2026-07-14_query-indexes.js.map +1 -0
- package/dist/server/avatar.js +33 -6
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/cliPasskeyPage.js +109 -11
- package/dist/server/cliPasskeyPage.js.map +1 -1
- package/dist/server/errors.js +8 -0
- package/dist/server/errors.js.map +1 -1
- package/dist/server/file.js +35 -12
- package/dist/server/file.js.map +1 -1
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.js +319 -56
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +367 -29
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/passkeyDevices.js +1 -0
- package/dist/server/passkeyDevices.js.map +1 -1
- package/dist/server/password.d.ts +7 -0
- package/dist/server/password.js +58 -0
- package/dist/server/password.js.map +1 -0
- package/dist/server/permissions.d.ts +1 -0
- package/dist/server/permissions.js +11 -2
- package/dist/server/permissions.js.map +1 -1
- package/dist/server/rateLimit.d.ts +11 -0
- package/dist/server/rateLimit.js +43 -4
- package/dist/server/rateLimit.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/dist/server/user.d.ts +1 -1
- package/dist/server/user.js +40 -9
- package/dist/server/user.js.map +1 -1
- package/dist/types/express.d.ts +3 -4
- package/dist/types/express.js.map +1 -1
- package/dist/utils/authJwt.d.ts +8 -0
- package/dist/utils/authJwt.js +25 -0
- package/dist/utils/authJwt.js.map +1 -0
- package/dist/utils/loadEnv.js +4 -0
- package/dist/utils/loadEnv.js.map +1 -1
- package/dist/utils/preKeySignature.d.ts +1 -1
- package/dist/utils/preKeySignature.js +7 -11
- package/dist/utils/preKeySignature.js.map +1 -1
- package/package.json +7 -7
- package/src/Database.ts +294 -152
- package/src/Spire.ts +412 -285
- package/src/__tests__/Database.spec.ts +126 -3
- package/src/__tests__/browserPasskeyAuthentication.spec.ts +192 -0
- package/src/__tests__/browserPasskeyRegistration.spec.ts +182 -0
- package/src/__tests__/cliPasskeyPage.spec.ts +6 -0
- package/src/__tests__/connectAuth.spec.ts +146 -4
- package/src/__tests__/deviceTokenRevalidation.spec.ts +57 -3
- package/src/__tests__/passkeyDevices.spec.ts +94 -0
- package/src/__tests__/passkeys.spec.ts +7 -2
- package/src/__tests__/password.spec.ts +223 -0
- package/src/__tests__/permissions.spec.ts +50 -0
- package/src/__tests__/preKeySignature.spec.ts +20 -3
- package/src/__tests__/serverManagement.spec.ts +262 -0
- package/src/db/schema.ts +0 -2
- package/src/migrations/2026-04-06_initial-schema.ts +4 -5
- package/src/migrations/2026-07-14_query-indexes.ts +34 -0
- package/src/server/avatar.ts +32 -6
- package/src/server/cliPasskeyPage.ts +109 -11
- package/src/server/errors.ts +7 -0
- package/src/server/file.ts +34 -13
- package/src/server/index.ts +494 -139
- package/src/server/passkey.ts +531 -31
- package/src/server/passkeyDevices.ts +1 -0
- package/src/server/password.ts +96 -0
- package/src/server/permissions.ts +20 -2
- package/src/server/rateLimit.ts +47 -4
- package/src/server/serverIcon.ts +199 -0
- package/src/server/user.ts +44 -9
- package/src/types/express.ts +3 -4
- package/src/utils/authJwt.ts +34 -0
- package/src/utils/loadEnv.ts +4 -0
- package/src/utils/preKeySignature.ts +12 -15
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js +0 -17
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +0 -1
- package/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
import {
|
|
8
8
|
setCryptoProfile,
|
|
9
9
|
xBoxKeyPairAsync,
|
|
10
|
-
|
|
11
|
-
xEncode,
|
|
10
|
+
xPreKeySignaturePayload,
|
|
12
11
|
xSignAsync,
|
|
13
12
|
xSignKeyPair,
|
|
14
13
|
XUtils,
|
|
@@ -25,7 +24,7 @@ async function makeSignedPreKey() {
|
|
|
25
24
|
const signKeys = xSignKeyPair();
|
|
26
25
|
const preKey = await xBoxKeyPairAsync();
|
|
27
26
|
const signature = await xSignAsync(
|
|
28
|
-
|
|
27
|
+
xPreKeySignaturePayload(preKey.publicKey, "signed"),
|
|
29
28
|
signKeys.secretKey,
|
|
30
29
|
);
|
|
31
30
|
return { preKey, signature, signKeys };
|
|
@@ -55,6 +54,7 @@ describe("prekey signature validation", () => {
|
|
|
55
54
|
signature,
|
|
56
55
|
},
|
|
57
56
|
XUtils.encodeHex(signKeys.publicKey),
|
|
57
|
+
"signed",
|
|
58
58
|
),
|
|
59
59
|
).resolves.toBe(true);
|
|
60
60
|
});
|
|
@@ -71,4 +71,21 @@ describe("prekey signature validation", () => {
|
|
|
71
71
|
}),
|
|
72
72
|
).resolves.toBe(false);
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
it("does not accept a signed prekey as a one-time prekey", async () => {
|
|
76
|
+
const { preKey, signature, signKeys } = await makeSignedPreKey();
|
|
77
|
+
|
|
78
|
+
await expect(
|
|
79
|
+
verifyPreKeyWsSignature(
|
|
80
|
+
{
|
|
81
|
+
deviceID: "device-a",
|
|
82
|
+
index: 1,
|
|
83
|
+
publicKey: preKey.publicKey,
|
|
84
|
+
signature,
|
|
85
|
+
},
|
|
86
|
+
XUtils.encodeHex(signKeys.publicKey),
|
|
87
|
+
"one-time",
|
|
88
|
+
),
|
|
89
|
+
).resolves.toBe(false);
|
|
90
|
+
});
|
|
74
91
|
});
|
|
@@ -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/db/schema.ts
CHANGED
|
@@ -276,10 +276,8 @@ export type ServiceMetricUpdate = Updateable<ServiceMetricsTable>;
|
|
|
276
276
|
|
|
277
277
|
export type UserRow = Selectable<UsersTable>;
|
|
278
278
|
export interface UsersTable {
|
|
279
|
-
hashAlgo: string;
|
|
280
279
|
lastSeen: string;
|
|
281
280
|
passwordHash: string;
|
|
282
|
-
passwordSalt: string;
|
|
283
281
|
userID: string;
|
|
284
282
|
username: string;
|
|
285
283
|
}
|
|
@@ -31,11 +31,10 @@ export async function up(db: Kysely<unknown>): Promise<void> {
|
|
|
31
31
|
await db.schema
|
|
32
32
|
.createTable("users")
|
|
33
33
|
.ifNotExists()
|
|
34
|
-
.addColumn("userID", "varchar(255)", (cb) => cb.primaryKey())
|
|
35
|
-
.addColumn("username", "varchar(255)", (cb) => cb.unique())
|
|
36
|
-
.addColumn("passwordHash", "text")
|
|
37
|
-
.addColumn("
|
|
38
|
-
.addColumn("lastSeen", "text")
|
|
34
|
+
.addColumn("userID", "varchar(255)", (cb) => cb.primaryKey().notNull())
|
|
35
|
+
.addColumn("username", "varchar(255)", (cb) => cb.unique().notNull())
|
|
36
|
+
.addColumn("passwordHash", "text", (cb) => cb.notNull())
|
|
37
|
+
.addColumn("lastSeen", "text", (cb) => cb.notNull())
|
|
39
38
|
.execute();
|
|
40
39
|
|
|
41
40
|
await db.schema
|
|
@@ -0,0 +1,34 @@
|
|
|
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 { Kysely } from "kysely";
|
|
8
|
+
|
|
9
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
10
|
+
await db.schema.dropIndex("channels_serverID_idx").ifExists().execute();
|
|
11
|
+
await db.schema.dropIndex("devices_owner_deleted_idx").ifExists().execute();
|
|
12
|
+
await db.schema.dropIndex("permissions_user_type_idx").ifExists().execute();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
16
|
+
await db.schema
|
|
17
|
+
.createIndex("devices_owner_deleted_idx")
|
|
18
|
+
.ifNotExists()
|
|
19
|
+
.on("devices")
|
|
20
|
+
.columns(["owner", "deleted"])
|
|
21
|
+
.execute();
|
|
22
|
+
await db.schema
|
|
23
|
+
.createIndex("channels_serverID_idx")
|
|
24
|
+
.ifNotExists()
|
|
25
|
+
.on("channels")
|
|
26
|
+
.column("serverID")
|
|
27
|
+
.execute();
|
|
28
|
+
await db.schema
|
|
29
|
+
.createIndex("permissions_user_type_idx")
|
|
30
|
+
.ifNotExists()
|
|
31
|
+
.on("permissions")
|
|
32
|
+
.columns(["userID", "resourceType"])
|
|
33
|
+
.execute();
|
|
34
|
+
}
|
package/src/server/avatar.ts
CHANGED
|
@@ -21,13 +21,20 @@ import { getParam, getUser } from "./utils.ts";
|
|
|
21
21
|
import { ALLOWED_IMAGE_TYPES, protect } from "./index.ts";
|
|
22
22
|
|
|
23
23
|
const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
|
|
24
|
+
const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
|
|
25
|
+
const avatarUpload = multer({
|
|
26
|
+
limits: { fields: 1, files: 1, fileSize: MAX_AVATAR_BYTES, parts: 2 },
|
|
27
|
+
});
|
|
24
28
|
|
|
25
29
|
// Avatars are public, unencrypted images. The body for the JSON path is just a
|
|
26
30
|
// base64-encoded image; nothing else. Do NOT reuse FilePayloadSchema here —
|
|
27
31
|
// that schema is for encrypted user-file uploads and requires nonce/owner/signed,
|
|
28
32
|
// which the avatar client never sends (and shouldn't).
|
|
29
33
|
const avatarJsonPayload = z.object({
|
|
30
|
-
file: z
|
|
34
|
+
file: z
|
|
35
|
+
.string()
|
|
36
|
+
.min(1)
|
|
37
|
+
.max(Math.ceil((MAX_AVATAR_BYTES * 4) / 3) + 4),
|
|
31
38
|
});
|
|
32
39
|
|
|
33
40
|
export const getAvatarRouter = () => {
|
|
@@ -47,6 +54,7 @@ export const getAvatarRouter = () => {
|
|
|
47
54
|
}
|
|
48
55
|
res.set("Content-type", typeDetails.mime);
|
|
49
56
|
res.set("Cache-control", "public, max-age=31536000");
|
|
57
|
+
res.set("Cross-Origin-Resource-Policy", "cross-origin");
|
|
50
58
|
|
|
51
59
|
const stream = fs.createReadStream(filePath);
|
|
52
60
|
stream.on("error", (_err) => {
|
|
@@ -56,7 +64,7 @@ export const getAvatarRouter = () => {
|
|
|
56
64
|
stream.pipe(res);
|
|
57
65
|
});
|
|
58
66
|
|
|
59
|
-
router.post("/:userID/json", protect, async (req, res) => {
|
|
67
|
+
router.post("/:userID/json", uploadLimiter, protect, async (req, res) => {
|
|
60
68
|
const parsed = avatarJsonPayload.safeParse(req.body);
|
|
61
69
|
if (!parsed.success) {
|
|
62
70
|
res.status(400).json({
|
|
@@ -73,13 +81,27 @@ export const getAvatarRouter = () => {
|
|
|
73
81
|
res.sendStatus(401);
|
|
74
82
|
return;
|
|
75
83
|
}
|
|
84
|
+
if (getParam(req, "userID") !== userDetails.userID) {
|
|
85
|
+
res.sendStatus(403);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
76
88
|
|
|
77
|
-
|
|
89
|
+
let buf: Buffer;
|
|
90
|
+
try {
|
|
91
|
+
buf = Buffer.from(XUtils.decodeBase64(payload.file));
|
|
92
|
+
} catch {
|
|
93
|
+
res.status(400).send({ error: "Avatar must be valid base64." });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (buf.byteLength > MAX_AVATAR_BYTES) {
|
|
97
|
+
res.sendStatus(413);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
78
100
|
const mimeType = await fileTypeFromBuffer(buf);
|
|
79
101
|
if (!ALLOWED_IMAGE_TYPES.includes(mimeType?.mime || "no/type")) {
|
|
80
102
|
res.status(400).send({
|
|
81
103
|
error:
|
|
82
|
-
"Unsupported file type. Expected jpeg, png, gif, apng, avif, or
|
|
104
|
+
"Unsupported file type. Expected jpeg, png, gif, apng, avif, or webp but received " +
|
|
83
105
|
String(mimeType?.ext),
|
|
84
106
|
});
|
|
85
107
|
return;
|
|
@@ -99,7 +121,7 @@ export const getAvatarRouter = () => {
|
|
|
99
121
|
"/:userID",
|
|
100
122
|
uploadLimiter,
|
|
101
123
|
protect,
|
|
102
|
-
|
|
124
|
+
avatarUpload.single("avatar"),
|
|
103
125
|
async (req, res) => {
|
|
104
126
|
const userDetails = getUser(req);
|
|
105
127
|
const deviceDetails = req.device;
|
|
@@ -108,6 +130,10 @@ export const getAvatarRouter = () => {
|
|
|
108
130
|
res.sendStatus(401);
|
|
109
131
|
return;
|
|
110
132
|
}
|
|
133
|
+
if (getParam(req, "userID") !== userDetails.userID) {
|
|
134
|
+
res.sendStatus(403);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
111
137
|
|
|
112
138
|
if (!req.file) {
|
|
113
139
|
res.sendStatus(400);
|
|
@@ -118,7 +144,7 @@ export const getAvatarRouter = () => {
|
|
|
118
144
|
if (!ALLOWED_IMAGE_TYPES.includes(mimeType?.mime || "no/type")) {
|
|
119
145
|
res.status(400).send({
|
|
120
146
|
error:
|
|
121
|
-
"Unsupported file type. Expected jpeg, png, gif, apng, avif, or
|
|
147
|
+
"Unsupported file type. Expected jpeg, png, gif, apng, avif, or webp but received " +
|
|
122
148
|
String(mimeType?.ext),
|
|
123
149
|
});
|
|
124
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 =
|
|
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
|
|
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
|
|
package/src/server/errors.ts
CHANGED
|
@@ -35,6 +35,7 @@ import type { ErrorRequestHandler } from "express";
|
|
|
35
35
|
|
|
36
36
|
import { randomUUID } from "node:crypto";
|
|
37
37
|
|
|
38
|
+
import multer from "multer";
|
|
38
39
|
import { ZodError } from "zod/v4";
|
|
39
40
|
|
|
40
41
|
/**
|
|
@@ -92,6 +93,12 @@ export const errorHandler =
|
|
|
92
93
|
status = 400;
|
|
93
94
|
clientMessage = "Validation failed";
|
|
94
95
|
details = err.issues;
|
|
96
|
+
} else if (err instanceof multer.MulterError) {
|
|
97
|
+
status = err.code === "LIMIT_FILE_SIZE" ? 413 : 400;
|
|
98
|
+
clientMessage =
|
|
99
|
+
err.code === "LIMIT_FILE_SIZE"
|
|
100
|
+
? "Uploaded file is too large"
|
|
101
|
+
: "Invalid multipart upload";
|
|
95
102
|
} else if (err instanceof AppError) {
|
|
96
103
|
status = err.status;
|
|
97
104
|
clientMessage = err.message;
|