@haven-chat-org/core 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/LICENSE +661 -0
- package/README.md +71 -0
- package/dist/crypto/backup.d.ts +87 -0
- package/dist/crypto/backup.js +62 -0
- package/dist/crypto/backup.js.map +1 -0
- package/dist/crypto/double-ratchet.d.ts +104 -0
- package/dist/crypto/double-ratchet.js +274 -0
- package/dist/crypto/double-ratchet.js.map +1 -0
- package/dist/crypto/file.d.ts +14 -0
- package/dist/crypto/file.js +20 -0
- package/dist/crypto/file.js.map +1 -0
- package/dist/crypto/index.d.ts +9 -0
- package/dist/crypto/index.js +10 -0
- package/dist/crypto/index.js.map +1 -0
- package/dist/crypto/keys.d.ts +61 -0
- package/dist/crypto/keys.js +79 -0
- package/dist/crypto/keys.js.map +1 -0
- package/dist/crypto/passphrase.d.ts +10 -0
- package/dist/crypto/passphrase.js +142 -0
- package/dist/crypto/passphrase.js.map +1 -0
- package/dist/crypto/profile.d.ts +31 -0
- package/dist/crypto/profile.js +73 -0
- package/dist/crypto/profile.js.map +1 -0
- package/dist/crypto/sender-keys.d.ts +76 -0
- package/dist/crypto/sender-keys.js +170 -0
- package/dist/crypto/sender-keys.js.map +1 -0
- package/dist/crypto/sender-keys.test.d.ts +1 -0
- package/dist/crypto/sender-keys.test.js +272 -0
- package/dist/crypto/sender-keys.test.js.map +1 -0
- package/dist/crypto/utils.d.ts +41 -0
- package/dist/crypto/utils.js +102 -0
- package/dist/crypto/utils.js.map +1 -0
- package/dist/crypto/x3dh.d.ts +45 -0
- package/dist/crypto/x3dh.js +106 -0
- package/dist/crypto/x3dh.js.map +1 -0
- package/dist/export/__tests__/archive.test.d.ts +1 -0
- package/dist/export/__tests__/archive.test.js +276 -0
- package/dist/export/__tests__/archive.test.js.map +1 -0
- package/dist/export/archive.d.ts +38 -0
- package/dist/export/archive.js +107 -0
- package/dist/export/archive.js.map +1 -0
- package/dist/export/index.d.ts +4 -0
- package/dist/export/index.js +4 -0
- package/dist/export/index.js.map +1 -0
- package/dist/export/reader.d.ts +27 -0
- package/dist/export/reader.js +101 -0
- package/dist/export/reader.js.map +1 -0
- package/dist/export/signing.d.ts +15 -0
- package/dist/export/signing.js +44 -0
- package/dist/export/signing.js.map +1 -0
- package/dist/export/types.d.ts +128 -0
- package/dist/export/types.js +3 -0
- package/dist/export/types.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/net/api.d.ts +200 -0
- package/dist/net/api.js +715 -0
- package/dist/net/api.js.map +1 -0
- package/dist/net/api.test.d.ts +1 -0
- package/dist/net/api.test.js +884 -0
- package/dist/net/api.test.js.map +1 -0
- package/dist/net/index.d.ts +2 -0
- package/dist/net/index.js +3 -0
- package/dist/net/index.js.map +1 -0
- package/dist/net/ws.d.ts +71 -0
- package/dist/net/ws.js +257 -0
- package/dist/net/ws.js.map +1 -0
- package/dist/store/index.d.ts +2 -0
- package/dist/store/index.js +2 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/memory.d.ts +24 -0
- package/dist/store/memory.js +50 -0
- package/dist/store/memory.js.map +1 -0
- package/dist/store/types.d.ts +23 -0
- package/dist/store/types.js +2 -0
- package/dist/store/types.js.map +1 -0
- package/dist/types.d.ts +850 -0
- package/dist/types.js +35 -0
- package/dist/types.js.map +1 -0
- package/package.json +41 -0
package/dist/net/api.js
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { getSodium, initSodium } from "../crypto/utils.js";
|
|
2
|
+
import { isLoginSuccess, } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Type-safe REST client for the Haven backend.
|
|
5
|
+
* Handles JWT token management and automatic refresh.
|
|
6
|
+
*/
|
|
7
|
+
export class HavenApi {
|
|
8
|
+
baseUrl;
|
|
9
|
+
accessToken = null;
|
|
10
|
+
refreshToken = null;
|
|
11
|
+
onTokenExpired;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
14
|
+
this.onTokenExpired = options.onTokenExpired;
|
|
15
|
+
}
|
|
16
|
+
/** Set auth tokens (after login/register/refresh). */
|
|
17
|
+
setTokens(access, refresh) {
|
|
18
|
+
this.accessToken = access;
|
|
19
|
+
this.refreshToken = refresh;
|
|
20
|
+
}
|
|
21
|
+
clearTokens() {
|
|
22
|
+
this.accessToken = null;
|
|
23
|
+
this.refreshToken = null;
|
|
24
|
+
}
|
|
25
|
+
get currentAccessToken() {
|
|
26
|
+
return this.accessToken;
|
|
27
|
+
}
|
|
28
|
+
// ─── Auth ────────────────────────────────────────
|
|
29
|
+
/** Fetch a PoW challenge from the server. */
|
|
30
|
+
async getChallenge() {
|
|
31
|
+
return this.get("/api/v1/auth/challenge");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Register a new account. Automatically fetches and solves a PoW challenge.
|
|
35
|
+
* The PoW solving runs in a Web Worker when available, otherwise falls back to main thread.
|
|
36
|
+
*/
|
|
37
|
+
async register(input) {
|
|
38
|
+
// 1. Fetch challenge
|
|
39
|
+
const { challenge, difficulty } = await this.getChallenge();
|
|
40
|
+
// 2. Solve PoW
|
|
41
|
+
const nonce = await solvePoW(challenge, difficulty);
|
|
42
|
+
// 3. Submit registration with PoW solution
|
|
43
|
+
const res = await this.post("/api/v1/auth/register", {
|
|
44
|
+
...input,
|
|
45
|
+
pow_challenge: challenge,
|
|
46
|
+
pow_nonce: nonce,
|
|
47
|
+
});
|
|
48
|
+
this.setTokens(res.access_token, res.refresh_token);
|
|
49
|
+
return res;
|
|
50
|
+
}
|
|
51
|
+
async login(req) {
|
|
52
|
+
const res = await this.post("/api/v1/auth/login", req);
|
|
53
|
+
if (isLoginSuccess(res)) {
|
|
54
|
+
this.setTokens(res.access_token, res.refresh_token);
|
|
55
|
+
}
|
|
56
|
+
return res;
|
|
57
|
+
}
|
|
58
|
+
async refresh() {
|
|
59
|
+
if (!this.refreshToken)
|
|
60
|
+
throw new Error("No refresh token");
|
|
61
|
+
const req = { refresh_token: this.refreshToken };
|
|
62
|
+
const res = await this.post("/api/v1/auth/refresh", req);
|
|
63
|
+
this.setTokens(res.access_token, res.refresh_token);
|
|
64
|
+
return res;
|
|
65
|
+
}
|
|
66
|
+
async logout() {
|
|
67
|
+
await this.post("/api/v1/auth/logout", {});
|
|
68
|
+
this.clearTokens();
|
|
69
|
+
}
|
|
70
|
+
async totpSetup() {
|
|
71
|
+
return this.post("/api/v1/auth/totp/setup", {});
|
|
72
|
+
}
|
|
73
|
+
async totpVerify(req) {
|
|
74
|
+
await this.post("/api/v1/auth/totp/verify", req);
|
|
75
|
+
}
|
|
76
|
+
async totpDisable() {
|
|
77
|
+
await this.delete("/api/v1/auth/totp");
|
|
78
|
+
}
|
|
79
|
+
async changePassword(req) {
|
|
80
|
+
await this.put("/api/v1/auth/password", req);
|
|
81
|
+
}
|
|
82
|
+
async getSessions() {
|
|
83
|
+
return this.get("/api/v1/auth/sessions");
|
|
84
|
+
}
|
|
85
|
+
async revokeSession(familyId) {
|
|
86
|
+
await this.delete(`/api/v1/auth/sessions/${familyId}`);
|
|
87
|
+
}
|
|
88
|
+
// ─── Users ────────────────────────────────────────
|
|
89
|
+
async getUserByUsername(username) {
|
|
90
|
+
return this.get(`/api/v1/users/search?username=${encodeURIComponent(username)}`);
|
|
91
|
+
}
|
|
92
|
+
// ─── Keys ────────────────────────────────────────
|
|
93
|
+
async getKeyBundle(userId) {
|
|
94
|
+
return this.get(`/api/v1/users/${userId}/keys`);
|
|
95
|
+
}
|
|
96
|
+
async uploadPreKeys(req) {
|
|
97
|
+
await this.post("/api/v1/keys/prekeys", req);
|
|
98
|
+
}
|
|
99
|
+
/** Delete all unused one-time prekeys from the server (stale keys whose private keys are lost). */
|
|
100
|
+
async clearPreKeys() {
|
|
101
|
+
await this.delete("/api/v1/keys/prekeys");
|
|
102
|
+
}
|
|
103
|
+
async getPreKeyCount() {
|
|
104
|
+
return this.get("/api/v1/keys/prekeys/count");
|
|
105
|
+
}
|
|
106
|
+
async updateKeys(req) {
|
|
107
|
+
await this.put("/api/v1/keys/identity", req);
|
|
108
|
+
}
|
|
109
|
+
// ─── Key Backup ─────────────────────────────────
|
|
110
|
+
async uploadKeyBackup(req) {
|
|
111
|
+
await this.put("/api/v1/keys/backup", req);
|
|
112
|
+
}
|
|
113
|
+
async getKeyBackup() {
|
|
114
|
+
return this.get("/api/v1/keys/backup");
|
|
115
|
+
}
|
|
116
|
+
async getKeyBackupStatus() {
|
|
117
|
+
return this.get("/api/v1/keys/backup/status");
|
|
118
|
+
}
|
|
119
|
+
async deleteKeyBackup() {
|
|
120
|
+
await this.delete("/api/v1/keys/backup");
|
|
121
|
+
}
|
|
122
|
+
// ─── Servers ─────────────────────────────────────
|
|
123
|
+
async listServers() {
|
|
124
|
+
return this.get("/api/v1/servers");
|
|
125
|
+
}
|
|
126
|
+
async createServer(req) {
|
|
127
|
+
return this.post("/api/v1/servers", req);
|
|
128
|
+
}
|
|
129
|
+
async getServer(serverId) {
|
|
130
|
+
return this.get(`/api/v1/servers/${serverId}`);
|
|
131
|
+
}
|
|
132
|
+
async getMyPermissions(serverId) {
|
|
133
|
+
return this.get(`/api/v1/servers/${serverId}/members/@me/permissions`);
|
|
134
|
+
}
|
|
135
|
+
async updateServer(serverId, req) {
|
|
136
|
+
return this.patch(`/api/v1/servers/${serverId}`, req);
|
|
137
|
+
}
|
|
138
|
+
async listServerChannels(serverId) {
|
|
139
|
+
return this.get(`/api/v1/servers/${serverId}/channels`);
|
|
140
|
+
}
|
|
141
|
+
async createChannel(serverId, req) {
|
|
142
|
+
return this.post(`/api/v1/servers/${serverId}/channels`, req);
|
|
143
|
+
}
|
|
144
|
+
// ─── Channels ────────────────────────────────────
|
|
145
|
+
async joinChannel(channelId) {
|
|
146
|
+
await this.post(`/api/v1/channels/${channelId}/join`, {});
|
|
147
|
+
}
|
|
148
|
+
async updateChannel(channelId, req) {
|
|
149
|
+
return this.put(`/api/v1/channels/${channelId}`, req);
|
|
150
|
+
}
|
|
151
|
+
async deleteChannel(channelId) {
|
|
152
|
+
await this.delete(`/api/v1/channels/${channelId}`);
|
|
153
|
+
}
|
|
154
|
+
async listDmChannels() {
|
|
155
|
+
return this.get("/api/v1/dm");
|
|
156
|
+
}
|
|
157
|
+
async createDm(req) {
|
|
158
|
+
return this.post("/api/v1/dm", req);
|
|
159
|
+
}
|
|
160
|
+
async createGroupDm(req) {
|
|
161
|
+
return this.post("/api/v1/dm/group", req);
|
|
162
|
+
}
|
|
163
|
+
async listChannelMembers(channelId) {
|
|
164
|
+
return this.get(`/api/v1/channels/${channelId}/members`);
|
|
165
|
+
}
|
|
166
|
+
async leaveChannel(channelId) {
|
|
167
|
+
await this.delete(`/api/v1/channels/${channelId}/leave`);
|
|
168
|
+
}
|
|
169
|
+
async hideChannel(channelId) {
|
|
170
|
+
await this.put(`/api/v1/channels/${channelId}/hide`, {});
|
|
171
|
+
}
|
|
172
|
+
async logExport(req) {
|
|
173
|
+
await this.post(`/api/v1/exports/log`, req);
|
|
174
|
+
}
|
|
175
|
+
async restoreServer(serverId, data) {
|
|
176
|
+
return this.post(`/api/v1/servers/${serverId}/restore`, data);
|
|
177
|
+
}
|
|
178
|
+
async importMessages(channelId, messages) {
|
|
179
|
+
return this.post(`/api/v1/channels/${channelId}/import-messages`, { messages });
|
|
180
|
+
}
|
|
181
|
+
// ─── Channel Categories ─────────────────────────
|
|
182
|
+
async listCategories(serverId) {
|
|
183
|
+
return this.get(`/api/v1/servers/${serverId}/categories`);
|
|
184
|
+
}
|
|
185
|
+
async createCategory(serverId, req) {
|
|
186
|
+
return this.post(`/api/v1/servers/${serverId}/categories`, req);
|
|
187
|
+
}
|
|
188
|
+
async updateCategory(serverId, categoryId, req) {
|
|
189
|
+
return this.put(`/api/v1/servers/${serverId}/categories/${categoryId}`, req);
|
|
190
|
+
}
|
|
191
|
+
async deleteCategory(serverId, categoryId) {
|
|
192
|
+
await this.delete(`/api/v1/servers/${serverId}/categories/${categoryId}`);
|
|
193
|
+
}
|
|
194
|
+
async reorderCategories(serverId, req) {
|
|
195
|
+
await this.put(`/api/v1/servers/${serverId}/categories/reorder`, req);
|
|
196
|
+
}
|
|
197
|
+
async reorderChannels(serverId, req) {
|
|
198
|
+
await this.put(`/api/v1/servers/${serverId}/channels/reorder`, req);
|
|
199
|
+
}
|
|
200
|
+
async setChannelCategory(channelId, req) {
|
|
201
|
+
return this.put(`/api/v1/channels/${channelId}/category`, req);
|
|
202
|
+
}
|
|
203
|
+
// ─── Roles & Permissions ────────────────────────
|
|
204
|
+
async listRoles(serverId) {
|
|
205
|
+
return this.get(`/api/v1/servers/${serverId}/roles`);
|
|
206
|
+
}
|
|
207
|
+
async createRole(serverId, req) {
|
|
208
|
+
return this.post(`/api/v1/servers/${serverId}/roles`, req);
|
|
209
|
+
}
|
|
210
|
+
async updateRole(serverId, roleId, req) {
|
|
211
|
+
return this.put(`/api/v1/servers/${serverId}/roles/${roleId}`, req);
|
|
212
|
+
}
|
|
213
|
+
async deleteRole(serverId, roleId) {
|
|
214
|
+
await this.delete(`/api/v1/servers/${serverId}/roles/${roleId}`);
|
|
215
|
+
}
|
|
216
|
+
async assignRole(serverId, userId, req) {
|
|
217
|
+
await this.put(`/api/v1/servers/${serverId}/members/${userId}/roles`, req);
|
|
218
|
+
}
|
|
219
|
+
async unassignRole(serverId, userId, roleId) {
|
|
220
|
+
await this.delete(`/api/v1/servers/${serverId}/members/${userId}/roles/${roleId}`);
|
|
221
|
+
}
|
|
222
|
+
async listOverwrites(channelId) {
|
|
223
|
+
return this.get(`/api/v1/channels/${channelId}/overwrites`);
|
|
224
|
+
}
|
|
225
|
+
async setOverwrite(channelId, req) {
|
|
226
|
+
return this.put(`/api/v1/channels/${channelId}/overwrites`, req);
|
|
227
|
+
}
|
|
228
|
+
async deleteOverwrite(channelId, targetType, targetId) {
|
|
229
|
+
await this.delete(`/api/v1/channels/${channelId}/overwrites/${targetType}/${targetId}`);
|
|
230
|
+
}
|
|
231
|
+
// ─── Messages ────────────────────────────────────
|
|
232
|
+
async getMessages(channelId, query) {
|
|
233
|
+
const params = new URLSearchParams();
|
|
234
|
+
if (query?.before)
|
|
235
|
+
params.set("before", query.before);
|
|
236
|
+
if (query?.limit)
|
|
237
|
+
params.set("limit", String(query.limit));
|
|
238
|
+
const qs = params.toString();
|
|
239
|
+
return this.get(`/api/v1/channels/${channelId}/messages${qs ? `?${qs}` : ""}`);
|
|
240
|
+
}
|
|
241
|
+
async sendMessage(channelId, req) {
|
|
242
|
+
return this.post(`/api/v1/channels/${channelId}/messages`, req);
|
|
243
|
+
}
|
|
244
|
+
async getChannelReactions(channelId) {
|
|
245
|
+
return this.get(`/api/v1/channels/${channelId}/reactions`);
|
|
246
|
+
}
|
|
247
|
+
async getMessageReactions(messageId) {
|
|
248
|
+
return this.get(`/api/v1/messages/${messageId}/reactions`);
|
|
249
|
+
}
|
|
250
|
+
async getPinnedMessages(channelId) {
|
|
251
|
+
return this.get(`/api/v1/channels/${channelId}/pins`);
|
|
252
|
+
}
|
|
253
|
+
async getPinnedMessageIds(channelId) {
|
|
254
|
+
return this.get(`/api/v1/channels/${channelId}/pin-ids`);
|
|
255
|
+
}
|
|
256
|
+
// ─── Attachments ─────────────────────────────────
|
|
257
|
+
/** Upload encrypted blob directly to backend. Returns attachment_id + storage_key. */
|
|
258
|
+
async uploadAttachment(blob) {
|
|
259
|
+
const headers = {
|
|
260
|
+
"Content-Type": "application/octet-stream",
|
|
261
|
+
};
|
|
262
|
+
if (this.accessToken) {
|
|
263
|
+
headers["Authorization"] = `Bearer ${this.accessToken}`;
|
|
264
|
+
}
|
|
265
|
+
const res = await fetch(`${this.baseUrl}/api/v1/attachments/upload`, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers,
|
|
268
|
+
body: blob,
|
|
269
|
+
});
|
|
270
|
+
if (!res.ok) {
|
|
271
|
+
if (res.status === 401 && this.onTokenExpired) {
|
|
272
|
+
this.onTokenExpired();
|
|
273
|
+
}
|
|
274
|
+
const err = await res.json().catch(() => ({
|
|
275
|
+
error: res.statusText,
|
|
276
|
+
status: res.status,
|
|
277
|
+
}));
|
|
278
|
+
throw new HavenApiError(err.error, err.status);
|
|
279
|
+
}
|
|
280
|
+
return res.json();
|
|
281
|
+
}
|
|
282
|
+
/** Download encrypted blob from backend. Returns raw bytes. */
|
|
283
|
+
async downloadAttachment(attachmentId) {
|
|
284
|
+
const headers = {};
|
|
285
|
+
if (this.accessToken) {
|
|
286
|
+
headers["Authorization"] = `Bearer ${this.accessToken}`;
|
|
287
|
+
}
|
|
288
|
+
const res = await fetch(`${this.baseUrl}/api/v1/attachments/${attachmentId}`, {
|
|
289
|
+
method: "GET",
|
|
290
|
+
headers,
|
|
291
|
+
});
|
|
292
|
+
if (!res.ok) {
|
|
293
|
+
if (res.status === 401 && this.onTokenExpired) {
|
|
294
|
+
this.onTokenExpired();
|
|
295
|
+
}
|
|
296
|
+
throw new HavenApiError(`Download failed: ${res.statusText}`, res.status);
|
|
297
|
+
}
|
|
298
|
+
return res.arrayBuffer();
|
|
299
|
+
}
|
|
300
|
+
// ─── Export ────────────────────────────────────────
|
|
301
|
+
/** Toggle DM export consent for a channel. */
|
|
302
|
+
async setExportConsent(channelId, allowed) {
|
|
303
|
+
await this.put(`/api/v1/channels/${channelId}/export-consent`, { export_allowed: allowed });
|
|
304
|
+
}
|
|
305
|
+
// ─── Invites ───────────────────────────────────────
|
|
306
|
+
async createInvite(serverId, req) {
|
|
307
|
+
return this.post(`/api/v1/servers/${serverId}/invites`, req);
|
|
308
|
+
}
|
|
309
|
+
async listInvites(serverId) {
|
|
310
|
+
return this.get(`/api/v1/servers/${serverId}/invites`);
|
|
311
|
+
}
|
|
312
|
+
async deleteInvite(serverId, inviteId) {
|
|
313
|
+
await this.delete(`/api/v1/servers/${serverId}/invites/${inviteId}`);
|
|
314
|
+
}
|
|
315
|
+
async joinByInvite(code) {
|
|
316
|
+
return this.post(`/api/v1/invites/${code}/join`, {});
|
|
317
|
+
}
|
|
318
|
+
// ─── Server Members ───────────────────────────────
|
|
319
|
+
async listServerMembers(serverId) {
|
|
320
|
+
return this.get(`/api/v1/servers/${serverId}/members?limit=100`);
|
|
321
|
+
}
|
|
322
|
+
async kickMember(serverId, userId) {
|
|
323
|
+
await this.delete(`/api/v1/servers/${serverId}/members/${userId}`);
|
|
324
|
+
}
|
|
325
|
+
async setNickname(serverId, nickname) {
|
|
326
|
+
await this.put(`/api/v1/servers/${serverId}/nickname`, { nickname });
|
|
327
|
+
}
|
|
328
|
+
async setMemberNickname(serverId, userId, nickname) {
|
|
329
|
+
await this.put(`/api/v1/servers/${serverId}/members/${userId}/nickname`, { nickname });
|
|
330
|
+
}
|
|
331
|
+
async leaveServer(serverId) {
|
|
332
|
+
await this.delete(`/api/v1/servers/${serverId}/members/@me`);
|
|
333
|
+
}
|
|
334
|
+
async deleteServer(serverId) {
|
|
335
|
+
await this.delete(`/api/v1/servers/${serverId}`);
|
|
336
|
+
}
|
|
337
|
+
// ─── Bans ──────────────────────────────────────────
|
|
338
|
+
async banMember(serverId, userId, req) {
|
|
339
|
+
return this.post(`/api/v1/servers/${serverId}/bans/${userId}`, req);
|
|
340
|
+
}
|
|
341
|
+
async revokeBan(serverId, userId) {
|
|
342
|
+
await this.delete(`/api/v1/servers/${serverId}/bans/${userId}`);
|
|
343
|
+
}
|
|
344
|
+
async listBans(serverId) {
|
|
345
|
+
return this.get(`/api/v1/servers/${serverId}/bans`);
|
|
346
|
+
}
|
|
347
|
+
// ─── Read States ─────────────────────────────────────
|
|
348
|
+
async markChannelRead(channelId) {
|
|
349
|
+
return this.put(`/api/v1/channels/${channelId}/read-state`, {});
|
|
350
|
+
}
|
|
351
|
+
async getReadStates() {
|
|
352
|
+
return this.get("/api/v1/channels/read-states");
|
|
353
|
+
}
|
|
354
|
+
// ─── Admin Dashboard ────────────────────────────────
|
|
355
|
+
async getAdminStats() {
|
|
356
|
+
return this.get("/api/v1/admin/stats");
|
|
357
|
+
}
|
|
358
|
+
async listAdminUsers(search, limit, offset) {
|
|
359
|
+
const params = new URLSearchParams();
|
|
360
|
+
if (search)
|
|
361
|
+
params.set("search", search);
|
|
362
|
+
if (limit !== undefined)
|
|
363
|
+
params.set("limit", String(limit));
|
|
364
|
+
if (offset !== undefined)
|
|
365
|
+
params.set("offset", String(offset));
|
|
366
|
+
const qs = params.toString();
|
|
367
|
+
return this.get(`/api/v1/admin/users${qs ? `?${qs}` : ""}`);
|
|
368
|
+
}
|
|
369
|
+
async setUserAdmin(userId, isAdmin) {
|
|
370
|
+
const body = { is_admin: isAdmin };
|
|
371
|
+
await this.put(`/api/v1/admin/users/${userId}/admin`, body);
|
|
372
|
+
}
|
|
373
|
+
async adminDeleteUser(userId) {
|
|
374
|
+
await this.delete(`/api/v1/admin/users/${userId}`);
|
|
375
|
+
}
|
|
376
|
+
// ─── Timeouts ───────────────────────────────────────
|
|
377
|
+
async timeoutMember(serverId, userId, durationSeconds, reason) {
|
|
378
|
+
const body = { duration_seconds: durationSeconds, reason };
|
|
379
|
+
await this.put(`/api/v1/servers/${serverId}/members/${userId}/timeout`, body);
|
|
380
|
+
}
|
|
381
|
+
async removeTimeout(serverId, userId) {
|
|
382
|
+
await this.put(`/api/v1/servers/${serverId}/members/${userId}/timeout`, { duration_seconds: 0 });
|
|
383
|
+
}
|
|
384
|
+
// ─── Bulk Delete ────────────────────────────────────
|
|
385
|
+
async bulkDeleteMessages(channelId, messageIds) {
|
|
386
|
+
const body = { message_ids: messageIds };
|
|
387
|
+
await this.post(`/api/v1/channels/${channelId}/messages/bulk-delete`, body);
|
|
388
|
+
}
|
|
389
|
+
// ─── Audit Log ──────────────────────────────────────
|
|
390
|
+
async getAuditLog(serverId, opts) {
|
|
391
|
+
const params = new URLSearchParams();
|
|
392
|
+
if (opts?.limit)
|
|
393
|
+
params.set("limit", String(opts.limit));
|
|
394
|
+
if (opts?.before)
|
|
395
|
+
params.set("before", opts.before);
|
|
396
|
+
const qs = params.toString();
|
|
397
|
+
return this.get(`/api/v1/servers/${serverId}/audit-log${qs ? `?${qs}` : ""}`);
|
|
398
|
+
}
|
|
399
|
+
// ─── Group DM Members ──────────────────────────────
|
|
400
|
+
async addGroupMember(channelId, userId) {
|
|
401
|
+
await this.post(`/api/v1/channels/${channelId}/members`, { user_id: userId });
|
|
402
|
+
}
|
|
403
|
+
// ─── Sender Keys ───────────────────────────────────
|
|
404
|
+
async distributeSenderKeys(channelId, req) {
|
|
405
|
+
await this.post(`/api/v1/channels/${channelId}/sender-keys`, req);
|
|
406
|
+
}
|
|
407
|
+
async getSenderKeys(channelId) {
|
|
408
|
+
return this.get(`/api/v1/channels/${channelId}/sender-keys`);
|
|
409
|
+
}
|
|
410
|
+
async getChannelMemberKeys(channelId) {
|
|
411
|
+
return this.get(`/api/v1/channels/${channelId}/members/keys`);
|
|
412
|
+
}
|
|
413
|
+
// ─── Link Previews ──────────────────────────────
|
|
414
|
+
async fetchLinkPreview(url) {
|
|
415
|
+
return this.get(`/api/v1/link-preview?url=${encodeURIComponent(url)}`);
|
|
416
|
+
}
|
|
417
|
+
// ─── Presence ─────────────────────────────────────
|
|
418
|
+
async getPresence(userIds) {
|
|
419
|
+
return this.get(`/api/v1/presence?user_ids=${userIds.join(",")}`);
|
|
420
|
+
}
|
|
421
|
+
// ─── User Profiles ──────────────────────────────
|
|
422
|
+
async getUserProfile(userId, serverId) {
|
|
423
|
+
const qs = serverId ? `?server_id=${serverId}` : "";
|
|
424
|
+
return this.get(`/api/v1/users/${userId}/profile${qs}`);
|
|
425
|
+
}
|
|
426
|
+
async updateProfile(req) {
|
|
427
|
+
return this.put(`/api/v1/users/profile`, req);
|
|
428
|
+
}
|
|
429
|
+
async uploadAvatar(blob) {
|
|
430
|
+
const headers = {
|
|
431
|
+
"Content-Type": "application/octet-stream",
|
|
432
|
+
};
|
|
433
|
+
if (this.accessToken) {
|
|
434
|
+
headers["Authorization"] = `Bearer ${this.accessToken}`;
|
|
435
|
+
}
|
|
436
|
+
const res = await fetch(`${this.baseUrl}/api/v1/users/avatar`, {
|
|
437
|
+
method: "POST",
|
|
438
|
+
headers,
|
|
439
|
+
body: blob,
|
|
440
|
+
});
|
|
441
|
+
if (!res.ok) {
|
|
442
|
+
if (res.status === 401 && this.onTokenExpired) {
|
|
443
|
+
this.onTokenExpired();
|
|
444
|
+
}
|
|
445
|
+
const err = await res.json().catch(() => ({
|
|
446
|
+
error: res.statusText,
|
|
447
|
+
status: res.status,
|
|
448
|
+
}));
|
|
449
|
+
throw new HavenApiError(err.error, err.status);
|
|
450
|
+
}
|
|
451
|
+
return res.json();
|
|
452
|
+
}
|
|
453
|
+
async uploadBanner(blob) {
|
|
454
|
+
const headers = {
|
|
455
|
+
"Content-Type": "application/octet-stream",
|
|
456
|
+
};
|
|
457
|
+
if (this.accessToken) {
|
|
458
|
+
headers["Authorization"] = `Bearer ${this.accessToken}`;
|
|
459
|
+
}
|
|
460
|
+
const res = await fetch(`${this.baseUrl}/api/v1/users/banner`, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
headers,
|
|
463
|
+
body: blob,
|
|
464
|
+
});
|
|
465
|
+
if (!res.ok) {
|
|
466
|
+
if (res.status === 401 && this.onTokenExpired) {
|
|
467
|
+
this.onTokenExpired();
|
|
468
|
+
}
|
|
469
|
+
const err = await res.json().catch(() => ({
|
|
470
|
+
error: res.statusText,
|
|
471
|
+
status: res.status,
|
|
472
|
+
}));
|
|
473
|
+
throw new HavenApiError(err.error, err.status);
|
|
474
|
+
}
|
|
475
|
+
return res.json();
|
|
476
|
+
}
|
|
477
|
+
// ─── Profile Key Distribution ────────────────────
|
|
478
|
+
async distributeProfileKeys(req) {
|
|
479
|
+
return this.put(`/api/v1/users/profile-keys`, req);
|
|
480
|
+
}
|
|
481
|
+
async getProfileKey(userId) {
|
|
482
|
+
return this.get(`/api/v1/users/${userId}/profile-key`);
|
|
483
|
+
}
|
|
484
|
+
// ─── Friends ──────────────────────────────────────
|
|
485
|
+
async listFriends() {
|
|
486
|
+
return this.get("/api/v1/friends");
|
|
487
|
+
}
|
|
488
|
+
async sendFriendRequest(req) {
|
|
489
|
+
return this.post("/api/v1/friends/request", req);
|
|
490
|
+
}
|
|
491
|
+
async acceptFriendRequest(friendshipId) {
|
|
492
|
+
return this.post(`/api/v1/friends/${friendshipId}/accept`, {});
|
|
493
|
+
}
|
|
494
|
+
async declineFriendRequest(friendshipId) {
|
|
495
|
+
await this.post(`/api/v1/friends/${friendshipId}/decline`, {});
|
|
496
|
+
}
|
|
497
|
+
async removeFriend(friendshipId) {
|
|
498
|
+
await this.delete(`/api/v1/friends/${friendshipId}`);
|
|
499
|
+
}
|
|
500
|
+
// ─── DM Requests ─────────────────────────────────
|
|
501
|
+
async listDmRequests() {
|
|
502
|
+
return this.get("/api/v1/dm/requests");
|
|
503
|
+
}
|
|
504
|
+
async handleDmRequest(channelId, req) {
|
|
505
|
+
await this.post(`/api/v1/dm/${channelId}/request`, req);
|
|
506
|
+
}
|
|
507
|
+
async updateDmPrivacy(req) {
|
|
508
|
+
await this.put("/api/v1/users/dm-privacy", req);
|
|
509
|
+
}
|
|
510
|
+
// ─── Blocked Users ─────────────────────────────
|
|
511
|
+
async blockUser(userId) {
|
|
512
|
+
await this.post(`/api/v1/users/${userId}/block`, {});
|
|
513
|
+
}
|
|
514
|
+
async unblockUser(userId) {
|
|
515
|
+
await this.delete(`/api/v1/users/${userId}/block`);
|
|
516
|
+
}
|
|
517
|
+
async getBlockedUsers() {
|
|
518
|
+
return this.get("/api/v1/users/blocked");
|
|
519
|
+
}
|
|
520
|
+
// ─── Reports ──────────────────────────────────────
|
|
521
|
+
async reportMessage(req) {
|
|
522
|
+
return this.post("/api/v1/reports", req);
|
|
523
|
+
}
|
|
524
|
+
// ─── Custom Emojis ──────────────────────────────
|
|
525
|
+
async listServerEmojis(serverId) {
|
|
526
|
+
return this.get(`/api/v1/servers/${serverId}/emojis`);
|
|
527
|
+
}
|
|
528
|
+
async uploadEmoji(serverId, name, imageData) {
|
|
529
|
+
const headers = {
|
|
530
|
+
"Content-Type": "application/octet-stream",
|
|
531
|
+
};
|
|
532
|
+
if (this.accessToken) {
|
|
533
|
+
headers["Authorization"] = `Bearer ${this.accessToken}`;
|
|
534
|
+
}
|
|
535
|
+
const res = await fetch(`${this.baseUrl}/api/v1/servers/${serverId}/emojis?name=${encodeURIComponent(name)}`, { method: "POST", headers, body: imageData });
|
|
536
|
+
if (!res.ok) {
|
|
537
|
+
if (res.status === 401 && this.onTokenExpired) {
|
|
538
|
+
this.onTokenExpired();
|
|
539
|
+
}
|
|
540
|
+
const err = await res.json().catch(() => ({
|
|
541
|
+
error: res.statusText,
|
|
542
|
+
status: res.status,
|
|
543
|
+
}));
|
|
544
|
+
throw new HavenApiError(err.error, err.status);
|
|
545
|
+
}
|
|
546
|
+
return res.json();
|
|
547
|
+
}
|
|
548
|
+
async renameEmoji(serverId, emojiId, name) {
|
|
549
|
+
return this.patch(`/api/v1/servers/${serverId}/emojis/${emojiId}`, { name });
|
|
550
|
+
}
|
|
551
|
+
async deleteEmoji(serverId, emojiId) {
|
|
552
|
+
await this.delete(`/api/v1/servers/${serverId}/emojis/${emojiId}`);
|
|
553
|
+
}
|
|
554
|
+
// ─── Server Icons ──────────────────────────────
|
|
555
|
+
async uploadServerIcon(serverId, blob) {
|
|
556
|
+
const headers = {
|
|
557
|
+
"Content-Type": "application/octet-stream",
|
|
558
|
+
};
|
|
559
|
+
if (this.accessToken) {
|
|
560
|
+
headers["Authorization"] = `Bearer ${this.accessToken}`;
|
|
561
|
+
}
|
|
562
|
+
const res = await fetch(`${this.baseUrl}/api/v1/servers/${serverId}/icon`, {
|
|
563
|
+
method: "POST",
|
|
564
|
+
headers,
|
|
565
|
+
body: blob,
|
|
566
|
+
});
|
|
567
|
+
if (!res.ok) {
|
|
568
|
+
if (res.status === 401 && this.onTokenExpired) {
|
|
569
|
+
this.onTokenExpired();
|
|
570
|
+
}
|
|
571
|
+
const err = await res.json().catch(() => ({
|
|
572
|
+
error: res.statusText,
|
|
573
|
+
status: res.status,
|
|
574
|
+
}));
|
|
575
|
+
throw new HavenApiError(err.error, err.status);
|
|
576
|
+
}
|
|
577
|
+
return res.json();
|
|
578
|
+
}
|
|
579
|
+
async deleteServerIcon(serverId) {
|
|
580
|
+
await this.delete(`/api/v1/servers/${serverId}/icon`);
|
|
581
|
+
}
|
|
582
|
+
// ─── Registration Invites ──────────────────────
|
|
583
|
+
/** Check if the instance requires an invite code to register (no auth needed). */
|
|
584
|
+
async checkInviteRequired() {
|
|
585
|
+
return this.get("/api/v1/auth/invite-required");
|
|
586
|
+
}
|
|
587
|
+
/** List the current user's registration invite codes. */
|
|
588
|
+
async listMyRegistrationInvites() {
|
|
589
|
+
return this.get("/api/v1/registration-invites");
|
|
590
|
+
}
|
|
591
|
+
// ─── Account Deletion ──────────────────────────
|
|
592
|
+
async deleteAccount(password) {
|
|
593
|
+
await this.post("/api/v1/auth/delete-account", { password });
|
|
594
|
+
}
|
|
595
|
+
// ─── Voice ──────────────────────────────────────
|
|
596
|
+
async joinVoice(channelId) {
|
|
597
|
+
return this.post(`/api/v1/voice/${channelId}/join`, {});
|
|
598
|
+
}
|
|
599
|
+
async leaveVoice(channelId) {
|
|
600
|
+
await this.post(`/api/v1/voice/${channelId}/leave`, {});
|
|
601
|
+
}
|
|
602
|
+
async getVoiceParticipants(channelId) {
|
|
603
|
+
return this.get(`/api/v1/voice/${channelId}/participants`);
|
|
604
|
+
}
|
|
605
|
+
async serverMuteUser(channelId, userId, muted) {
|
|
606
|
+
await this.put(`/api/v1/voice/${channelId}/members/${userId}/mute`, { muted });
|
|
607
|
+
}
|
|
608
|
+
async serverDeafenUser(channelId, userId, deafened) {
|
|
609
|
+
await this.put(`/api/v1/voice/${channelId}/members/${userId}/deafen`, { deafened });
|
|
610
|
+
}
|
|
611
|
+
// ─── GIF Search ─────────────────────────────────
|
|
612
|
+
async searchGifs(query, offset) {
|
|
613
|
+
const params = new URLSearchParams({ q: query });
|
|
614
|
+
if (offset !== undefined)
|
|
615
|
+
params.set("offset", String(offset));
|
|
616
|
+
return this.get(`/api/v1/gifs/search?${params}`);
|
|
617
|
+
}
|
|
618
|
+
async trendingGifs() {
|
|
619
|
+
return this.get("/api/v1/gifs/trending");
|
|
620
|
+
}
|
|
621
|
+
// ─── HTTP Helpers ────────────────────────────────
|
|
622
|
+
async get(path) {
|
|
623
|
+
return this.request("GET", path);
|
|
624
|
+
}
|
|
625
|
+
async post(path, body) {
|
|
626
|
+
return this.request("POST", path, body);
|
|
627
|
+
}
|
|
628
|
+
async put(path, body) {
|
|
629
|
+
return this.request("PUT", path, body);
|
|
630
|
+
}
|
|
631
|
+
async patch(path, body) {
|
|
632
|
+
return this.request("PATCH", path, body);
|
|
633
|
+
}
|
|
634
|
+
async delete(path) {
|
|
635
|
+
return this.request("DELETE", path);
|
|
636
|
+
}
|
|
637
|
+
async request(method, path, body) {
|
|
638
|
+
const headers = { "Content-Type": "application/json" };
|
|
639
|
+
if (this.accessToken) {
|
|
640
|
+
headers["Authorization"] = `Bearer ${this.accessToken}`;
|
|
641
|
+
}
|
|
642
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
643
|
+
method,
|
|
644
|
+
headers,
|
|
645
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
646
|
+
});
|
|
647
|
+
if (!res.ok) {
|
|
648
|
+
if (res.status === 401 && this.onTokenExpired) {
|
|
649
|
+
this.onTokenExpired();
|
|
650
|
+
}
|
|
651
|
+
const err = await res.json().catch(() => ({
|
|
652
|
+
error: res.statusText,
|
|
653
|
+
status: res.status,
|
|
654
|
+
}));
|
|
655
|
+
throw new HavenApiError(err.error, err.status);
|
|
656
|
+
}
|
|
657
|
+
const text = await res.text();
|
|
658
|
+
return (text ? JSON.parse(text) : undefined);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
export class HavenApiError extends Error {
|
|
662
|
+
status;
|
|
663
|
+
constructor(message, status) {
|
|
664
|
+
super(message);
|
|
665
|
+
this.name = "HavenApiError";
|
|
666
|
+
this.status = status;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// ─── Proof-of-Work Solver ────────────────────────────
|
|
670
|
+
/**
|
|
671
|
+
* Solve a PoW challenge: find a nonce such that SHA-256(challenge + nonce)
|
|
672
|
+
* has at least `difficulty` leading zero bits.
|
|
673
|
+
* Uses libsodium's crypto_hash_sha256 (works in insecure HTTP contexts,
|
|
674
|
+
* unlike crypto.subtle which requires HTTPS or localhost).
|
|
675
|
+
*/
|
|
676
|
+
async function solvePoW(challenge, difficulty) {
|
|
677
|
+
await initSodium();
|
|
678
|
+
const sodium = getSodium();
|
|
679
|
+
const encoder = new TextEncoder();
|
|
680
|
+
const prefix = encoder.encode(challenge);
|
|
681
|
+
for (let nonce = 0;; nonce++) {
|
|
682
|
+
const nonceStr = String(nonce);
|
|
683
|
+
const nonceBytes = encoder.encode(nonceStr);
|
|
684
|
+
// Concatenate challenge + nonce
|
|
685
|
+
const data = new Uint8Array(prefix.length + nonceBytes.length);
|
|
686
|
+
data.set(prefix);
|
|
687
|
+
data.set(nonceBytes, prefix.length);
|
|
688
|
+
const hash = sodium.crypto_hash_sha256(data);
|
|
689
|
+
if (hasLeadingZeroBits(hash, difficulty)) {
|
|
690
|
+
return nonceStr;
|
|
691
|
+
}
|
|
692
|
+
// Yield to event loop every 10000 iterations to avoid blocking UI
|
|
693
|
+
if (nonce % 10000 === 0 && nonce > 0) {
|
|
694
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/** Check if a hash has at least `n` leading zero bits. */
|
|
699
|
+
function hasLeadingZeroBits(hash, n) {
|
|
700
|
+
let bits = 0;
|
|
701
|
+
for (const byte of hash) {
|
|
702
|
+
if (byte === 0) {
|
|
703
|
+
bits += 8;
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
// Count leading zeros of this byte
|
|
707
|
+
bits += Math.clz32(byte) - 24; // clz32 counts 32-bit, byte is 8-bit
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
if (bits >= n)
|
|
711
|
+
return true;
|
|
712
|
+
}
|
|
713
|
+
return bits >= n;
|
|
714
|
+
}
|
|
715
|
+
//# sourceMappingURL=api.js.map
|