create-handover 0.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.
@@ -0,0 +1,322 @@
1
+ import { strict as assert } from "node:assert";
2
+ import test from "node:test";
3
+
4
+ import { Handover, HandoverError } from "./handover";
5
+
6
+ function mockJsonResponse(status: number, payload: unknown): Response {
7
+ return new Response(JSON.stringify(payload), {
8
+ status,
9
+ headers: {
10
+ "Content-Type": "application/json",
11
+ },
12
+ });
13
+ }
14
+
15
+ test("login + session integration contract", async () => {
16
+ const originalFetch = globalThis.fetch;
17
+ const calls: Array<{ url: string; method: string }> = [];
18
+
19
+ globalThis.fetch = (async (url: URL | RequestInfo, init?: RequestInit) => {
20
+ const path = typeof url === "string" ? url : String(url);
21
+ calls.push({ url: path, method: init?.method ?? "GET" });
22
+
23
+ if (path.endsWith("/admin/login")) {
24
+ return mockJsonResponse(200, {
25
+ token: "session-token",
26
+ user: { id: "u1", username: "owner", role: "owner", status: "active" },
27
+ });
28
+ }
29
+
30
+ if (path.endsWith("/admin/session")) {
31
+ return mockJsonResponse(200, {
32
+ isValid: true,
33
+ user: { id: "u1", username: "owner", role: "owner", status: "active" },
34
+ });
35
+ }
36
+
37
+ return mockJsonResponse(404, { error: "not found" });
38
+ }) as typeof fetch;
39
+
40
+ try {
41
+ const sdk = new Handover({ apiKey: "ho_live_test", url: "https://example.com" });
42
+ const loginResult = await sdk.loginAdmin("owner", "Password123");
43
+ assert.equal(loginResult.token, "session-token");
44
+
45
+ const session = await sdk.getAdminSession("session-token");
46
+ assert.equal(session.isValid, true);
47
+ assert.equal(session.user?.username, "owner");
48
+
49
+ assert.equal(calls.some((c) => c.url.endsWith("/admin/login") && c.method === "POST"), true);
50
+ assert.equal(calls.some((c) => c.url.endsWith("/admin/session") && c.method === "POST"), true);
51
+ } finally {
52
+ globalThis.fetch = originalFetch;
53
+ }
54
+ });
55
+
56
+ test("logout and protected routes reject invalid session", async () => {
57
+ const originalFetch = globalThis.fetch;
58
+
59
+ globalThis.fetch = (async () => {
60
+ return mockJsonResponse(401, { error: "Unauthorized" });
61
+ }) as typeof fetch;
62
+
63
+ try {
64
+ const sdk = new Handover({ apiKey: "ho_live_test", url: "https://example.com" });
65
+
66
+ await assert.rejects(() => sdk.logoutAdmin("bad-session"), (err: unknown) => {
67
+ return err instanceof HandoverError && err.code === "UNAUTHORIZED";
68
+ });
69
+
70
+ await assert.rejects(() => sdk.updateText("hero_title", "New", "bad-session"), (err: unknown) => {
71
+ return err instanceof HandoverError && err.code === "UNAUTHORIZED";
72
+ });
73
+ } finally {
74
+ globalThis.fetch = originalFetch;
75
+ }
76
+ });
77
+
78
+ test("role-based authorization returns FORBIDDEN for user management", async () => {
79
+ const originalFetch = globalThis.fetch;
80
+
81
+ globalThis.fetch = (async () => {
82
+ return mockJsonResponse(403, { error: "Forbidden" });
83
+ }) as typeof fetch;
84
+
85
+ try {
86
+ const sdk = new Handover({ apiKey: "ho_live_test", url: "https://example.com" });
87
+
88
+ await assert.rejects(
89
+ () => sdk.updateAdminUser("session", { userId: "u2", role: "owner" }),
90
+ (err: unknown) => err instanceof HandoverError && err.code === "FORBIDDEN",
91
+ );
92
+ } finally {
93
+ globalThis.fetch = originalFetch;
94
+ }
95
+ });
96
+
97
+ test("legacy public content flow still works via POST /content", async () => {
98
+ const originalFetch = globalThis.fetch;
99
+ const calls: Array<{ url: string; method: string; headers?: HeadersInit }> = [];
100
+
101
+ globalThis.fetch = (async (url: URL | RequestInfo, init?: RequestInit) => {
102
+ const path = typeof url === "string" ? url : String(url);
103
+ calls.push({ url: path, method: init?.method ?? "GET", headers: init?.headers });
104
+
105
+ if (path.endsWith("/content") && init?.method === "POST") {
106
+ return mockJsonResponse(200, {
107
+ text: { hero_title: "Hello" },
108
+ images: [],
109
+ });
110
+ }
111
+
112
+ return mockJsonResponse(404, { error: "not found" });
113
+ }) as typeof fetch;
114
+
115
+ try {
116
+ const sdk = new Handover({ apiKey: "ho_live_test", url: "https://example.com" });
117
+ const content = await sdk.getContent();
118
+
119
+ assert.equal(content.text.hero_title, "Hello");
120
+ assert.equal(Array.isArray(content.images), true);
121
+ assert.equal(calls.some((c) => c.url.includes("?apiKey=")), false);
122
+ assert.equal(calls.some((c) => c.url.endsWith("/content") && c.method === "POST"), true);
123
+ } finally {
124
+ globalThis.fetch = originalFetch;
125
+ }
126
+ });
127
+
128
+ test("legacy content fallback uses GET /content without leaking apiKey query", async () => {
129
+ const originalFetch = globalThis.fetch;
130
+ const calls: Array<{ url: string; method: string }> = [];
131
+
132
+ globalThis.fetch = (async (url: URL | RequestInfo, init?: RequestInit) => {
133
+ const path = typeof url === "string" ? url : String(url);
134
+ const method = init?.method ?? "GET";
135
+ calls.push({ url: path, method });
136
+
137
+ if (path.endsWith("/content") && method === "POST") {
138
+ return mockJsonResponse(405, { error: "method not allowed" });
139
+ }
140
+
141
+ if (path.endsWith("/content") && method === "GET") {
142
+ return mockJsonResponse(200, {
143
+ text: { fallback_title: "Legacy" },
144
+ images: [],
145
+ });
146
+ }
147
+
148
+ return mockJsonResponse(404, { error: "not found" });
149
+ }) as typeof fetch;
150
+
151
+ try {
152
+ const sdk = new Handover({ apiKey: "ho_live_test", url: "https://example.com" });
153
+ const content = await sdk.getContent();
154
+
155
+ assert.equal(content.text.fallback_title, "Legacy");
156
+ assert.equal(calls.some((c) => c.method === "POST" && c.url.endsWith("/content")), true);
157
+ assert.equal(calls.some((c) => c.method === "GET" && c.url.endsWith("/content")), true);
158
+ assert.equal(calls.some((c) => c.url.includes("?apiKey=")), false);
159
+ } finally {
160
+ globalThis.fetch = originalFetch;
161
+ }
162
+ });
163
+
164
+ test("legacy verifyPassword flow remains backward-compatible", async () => {
165
+ const originalFetch = globalThis.fetch;
166
+ let callCount = 0;
167
+
168
+ globalThis.fetch = (async () => {
169
+ callCount += 1;
170
+ if (callCount === 1) {
171
+ return mockJsonResponse(401, { error: "invalid password" });
172
+ }
173
+ return mockJsonResponse(200, { isValid: true });
174
+ }) as typeof fetch;
175
+
176
+ try {
177
+ const sdk = new Handover({ apiKey: "ho_live_test", url: "https://example.com" });
178
+ const first = await sdk.verifyPassword("wrong");
179
+ const second = await sdk.verifyPassword("right");
180
+
181
+ assert.equal(first, false);
182
+ assert.equal(second, true);
183
+ } finally {
184
+ globalThis.fetch = originalFetch;
185
+ }
186
+ });
187
+
188
+ test("user management CRUD flow works for admin role", async () => {
189
+ const originalFetch = globalThis.fetch;
190
+ const users: Array<{ id: string; username: string; role: string; status: string; lastLoginAt?: number }> = [
191
+ { id: "u1", username: "owner", role: "owner", status: "active" },
192
+ ];
193
+
194
+ globalThis.fetch = (async (url: URL | RequestInfo, init?: RequestInit) => {
195
+ const path = typeof url === "string" ? url : String(url);
196
+ const body = init?.body ? JSON.parse(String(init.body)) : {};
197
+
198
+ if (path.endsWith("/admin/users") && init?.method === "POST") {
199
+ return mockJsonResponse(200, users);
200
+ }
201
+
202
+ if (path.endsWith("/admin/users/create") && init?.method === "POST") {
203
+ const created = {
204
+ id: `u${users.length + 1}`,
205
+ username: body.username,
206
+ role: body.role,
207
+ status: "active",
208
+ };
209
+ users.push(created);
210
+ return mockJsonResponse(200, created);
211
+ }
212
+
213
+ if (path.endsWith("/admin/users/update") && init?.method === "POST") {
214
+ const user = users.find((u) => u.id === body.userId);
215
+ if (!user) {
216
+ return mockJsonResponse(404, { error: "User not found" });
217
+ }
218
+ if (body.status) {
219
+ user.status = body.status;
220
+ }
221
+ if (body.role) {
222
+ user.role = body.role;
223
+ }
224
+ return mockJsonResponse(200, { success: true });
225
+ }
226
+
227
+ if (path.endsWith("/admin/users/delete") && init?.method === "POST") {
228
+ const index = users.findIndex((u) => u.id === body.userId);
229
+ if (index === -1) {
230
+ return mockJsonResponse(404, { error: "User not found" });
231
+ }
232
+ if (users[index].role === "owner") {
233
+ return mockJsonResponse(409, { error: "Owner account cannot be deleted" });
234
+ }
235
+ users.splice(index, 1);
236
+ return mockJsonResponse(200, { success: true });
237
+ }
238
+
239
+ return mockJsonResponse(404, { error: "not found" });
240
+ }) as typeof fetch;
241
+
242
+ try {
243
+ const sdk = new Handover({ apiKey: "ho_live_test", url: "https://example.com" });
244
+
245
+ const created = await sdk.createAdminUser("admin-session", {
246
+ username: "editor",
247
+ password: "Password123",
248
+ role: "editor",
249
+ });
250
+ assert.equal(created.username, "editor");
251
+
252
+ let listed = await sdk.listAdminUsers("admin-session");
253
+ assert.equal(listed.length, 2);
254
+
255
+ await sdk.updateAdminUser("admin-session", {
256
+ userId: created.id,
257
+ status: "disabled",
258
+ });
259
+
260
+ listed = await sdk.listAdminUsers("admin-session");
261
+ assert.equal(listed.find((u) => u.id === created.id)?.status, "disabled");
262
+
263
+ await sdk.deleteAdminUser("admin-session", created.id);
264
+ listed = await sdk.listAdminUsers("admin-session");
265
+ assert.equal(listed.some((u) => u.id === created.id), false);
266
+ } finally {
267
+ globalThis.fetch = originalFetch;
268
+ }
269
+ });
270
+
271
+ test("role-guarded user management actions reject editor role", async () => {
272
+ const originalFetch = globalThis.fetch;
273
+
274
+ globalThis.fetch = (async () => {
275
+ return mockJsonResponse(403, { error: "Forbidden" });
276
+ }) as typeof fetch;
277
+
278
+ try {
279
+ const sdk = new Handover({ apiKey: "ho_live_test", url: "https://example.com" });
280
+
281
+ await assert.rejects(
282
+ () => sdk.createAdminUser("editor-session", { username: "x", password: "Password123", role: "viewer" }),
283
+ (err: unknown) => err instanceof HandoverError && err.code === "FORBIDDEN",
284
+ );
285
+
286
+ await assert.rejects(
287
+ () => sdk.deleteAdminUser("editor-session", "u2"),
288
+ (err: unknown) => err instanceof HandoverError && err.code === "FORBIDDEN",
289
+ );
290
+ } finally {
291
+ globalThis.fetch = originalFetch;
292
+ }
293
+ });
294
+
295
+ test("kill switch blocks write operations with HANDOVER_LOCKED", async () => {
296
+ const originalFetch = globalThis.fetch;
297
+
298
+ globalThis.fetch = (async () => {
299
+ return mockJsonResponse(402, { error: "HANDOVER_LOCKED" });
300
+ }) as typeof fetch;
301
+
302
+ try {
303
+ const sdk = new Handover({ apiKey: "ho_live_test", url: "https://example.com" });
304
+
305
+ await assert.rejects(
306
+ () => sdk.updateText("hero_title", "blocked", "session-token"),
307
+ (err: unknown) => err instanceof HandoverError && err.code === "HANDOVER_LOCKED",
308
+ );
309
+
310
+ await assert.rejects(
311
+ () => sdk.deleteText("hero_title", "session-token"),
312
+ (err: unknown) => err instanceof HandoverError && err.code === "HANDOVER_LOCKED",
313
+ );
314
+
315
+ await assert.rejects(
316
+ () => sdk.deleteImage("img_1", "session-token"),
317
+ (err: unknown) => err instanceof HandoverError && err.code === "HANDOVER_LOCKED",
318
+ );
319
+ } finally {
320
+ globalThis.fetch = originalFetch;
321
+ }
322
+ });
@@ -0,0 +1,389 @@
1
+ const API_KEY_HEADER = "X-Handover-Api-Key";
2
+ const ADMIN_SESSION_HEADER = "X-Handover-Admin-Session";
3
+
4
+ export class HandoverError extends Error {
5
+ readonly code: string;
6
+
7
+ constructor(code: string) {
8
+ super(code);
9
+ this.name = "HandoverError";
10
+ this.code = code;
11
+ }
12
+ }
13
+
14
+ export class Handover {
15
+ private url: string;
16
+ private apiKey: string;
17
+
18
+ constructor(config: { apiKey: string, url: string }) {
19
+ this.apiKey = config.apiKey.trim();
20
+ this.url = config.url.replace(/\/$/, "");
21
+ }
22
+
23
+ private jsonHeaders() {
24
+ return {
25
+ "Content-Type": "application/json",
26
+ [API_KEY_HEADER]: this.apiKey,
27
+ };
28
+ }
29
+
30
+ private adminHeaders(sessionToken: string) {
31
+ return {
32
+ ...this.jsonHeaders(),
33
+ [ADMIN_SESSION_HEADER]: sessionToken,
34
+ };
35
+ }
36
+
37
+ private async throwForError(res: Response, fallbackCode: string) {
38
+ if (res.status === 402) {
39
+ throw new HandoverError("HANDOVER_LOCKED");
40
+ }
41
+
42
+ if (res.status === 401) {
43
+ throw new HandoverError("UNAUTHORIZED");
44
+ }
45
+
46
+ if (res.status === 413) {
47
+ throw new HandoverError("STORAGE_LIMIT_EXCEEDED");
48
+ }
49
+
50
+ if (res.status === 415) {
51
+ throw new HandoverError("INVALID_IMAGE_TYPE");
52
+ }
53
+
54
+ if (res.status === 429) {
55
+ throw new HandoverError("AUTH_RATE_LIMITED");
56
+ }
57
+
58
+ if (res.status === 403) {
59
+ throw new HandoverError("FORBIDDEN");
60
+ }
61
+
62
+ if (res.status === 412) {
63
+ throw new HandoverError("ADMIN_NOT_BOOTSTRAPPED");
64
+ }
65
+
66
+ throw new HandoverError(fallbackCode);
67
+ }
68
+
69
+ async getContent(): Promise<{ text: Record<string, unknown>, images: Array<{ _id: string, url: string, altText?: string, mimeType: string }> }> {
70
+ const preferred = await fetch(`${this.url}/content`, {
71
+ method: "POST",
72
+ headers: this.jsonHeaders(),
73
+ body: "{}",
74
+ cache: "no-store",
75
+ });
76
+
77
+ if (preferred.ok) {
78
+ return await preferred.json();
79
+ }
80
+
81
+ if (preferred.status !== 404 && preferred.status !== 405) {
82
+ await this.throwForError(preferred, "FAILED_TO_FETCH_CONTENT");
83
+ }
84
+
85
+ const fallback = await fetch(`${this.url}/content`, {
86
+ method: "GET",
87
+ headers: this.jsonHeaders(),
88
+ cache: "no-store",
89
+ });
90
+
91
+ if (!fallback.ok) {
92
+ await this.throwForError(fallback, "FAILED_TO_FETCH_CONTENT");
93
+ }
94
+
95
+ return await fallback.json();
96
+ }
97
+
98
+ async deleteImage(imageId: string, sessionToken: string): Promise<boolean> {
99
+ const res = await fetch(`${this.url}/storage/delete`, {
100
+ method: "POST",
101
+ headers: this.adminHeaders(sessionToken),
102
+ body: JSON.stringify({ imageId }),
103
+ });
104
+
105
+ if (!res.ok) {
106
+ await this.throwForError(res, "FAILED_TO_DELETE_IMAGE");
107
+ }
108
+
109
+ return true;
110
+ }
111
+
112
+ async updateText(key: string, value: unknown, sessionToken: string): Promise<boolean> {
113
+ const res = await fetch(`${this.url}/update_content`, {
114
+ method: "POST",
115
+ headers: this.adminHeaders(sessionToken),
116
+ body: JSON.stringify({ key, value }),
117
+ });
118
+
119
+ if (!res.ok) {
120
+ await this.throwForError(res, "FAILED_TO_UPDATE_CONTENT");
121
+ }
122
+
123
+ return true;
124
+ }
125
+
126
+ async deleteText(key: string, sessionToken: string): Promise<boolean> {
127
+ const res = await fetch(`${this.url}/content/delete_key`, {
128
+ method: "POST",
129
+ headers: this.adminHeaders(sessionToken),
130
+ body: JSON.stringify({ key }),
131
+ });
132
+
133
+ if (!res.ok) {
134
+ await this.throwForError(res, "FAILED_TO_DELETE_CONTENT");
135
+ }
136
+
137
+ return true;
138
+ }
139
+
140
+ async verifyPassword(password: string): Promise<boolean> {
141
+ const res = await fetch(`${this.url}/verify`, {
142
+ method: "POST",
143
+ headers: this.jsonHeaders(),
144
+ body: JSON.stringify({ password }),
145
+ });
146
+
147
+ if (res.status === 401) {
148
+ return false;
149
+ }
150
+
151
+ if (!res.ok) {
152
+ await this.throwForError(res, "FAILED_TO_VERIFY_PASSWORD");
153
+ }
154
+
155
+ const data = await res.json();
156
+ return data.isValid;
157
+ }
158
+
159
+ async bootstrapAdmin(clientPassword: string, username: string, password: string): Promise<{ token: string; user: { id: string; username: string; role: string; status: string } }> {
160
+ const res = await fetch(`${this.url}/admin/bootstrap`, {
161
+ method: "POST",
162
+ headers: this.jsonHeaders(),
163
+ body: JSON.stringify({ clientPassword, username, password }),
164
+ });
165
+
166
+ if (res.status === 401) {
167
+ throw new HandoverError("INVALID_PASSWORD");
168
+ }
169
+
170
+ if (res.status === 409) {
171
+ throw new HandoverError("ADMIN_USERS_ALREADY_INITIALIZED");
172
+ }
173
+
174
+ if (!res.ok) {
175
+ await this.throwForError(res, "FAILED_TO_BOOTSTRAP_ADMIN");
176
+ }
177
+
178
+ return await res.json();
179
+ }
180
+
181
+ async loginAdmin(username: string, password: string): Promise<{ token: string; user: { id: string; username: string; role: string; status: string } }> {
182
+ const res = await fetch(`${this.url}/admin/login`, {
183
+ method: "POST",
184
+ headers: this.jsonHeaders(),
185
+ body: JSON.stringify({ username, password }),
186
+ });
187
+
188
+ if (res.status === 401) {
189
+ throw new HandoverError("INVALID_CREDENTIALS");
190
+ }
191
+
192
+ if (res.status === 423) {
193
+ throw new HandoverError("ACCOUNT_DISABLED");
194
+ }
195
+
196
+ if (!res.ok) {
197
+ await this.throwForError(res, "FAILED_TO_LOGIN");
198
+ }
199
+
200
+ return await res.json();
201
+ }
202
+
203
+ async logoutAdmin(sessionToken: string): Promise<boolean> {
204
+ const res = await fetch(`${this.url}/admin/logout`, {
205
+ method: "POST",
206
+ headers: this.adminHeaders(sessionToken),
207
+ body: "{}",
208
+ });
209
+
210
+ if (!res.ok) {
211
+ await this.throwForError(res, "FAILED_TO_LOGOUT");
212
+ }
213
+
214
+ return true;
215
+ }
216
+
217
+ async getAdminSession(sessionToken: string): Promise<{ isValid: boolean; user?: { id: string; username: string; role: string; status: string } }> {
218
+ const res = await fetch(`${this.url}/admin/session`, {
219
+ method: "POST",
220
+ headers: this.adminHeaders(sessionToken),
221
+ body: "{}",
222
+ });
223
+
224
+ if (!res.ok) {
225
+ await this.throwForError(res, "FAILED_TO_GET_SESSION");
226
+ }
227
+
228
+ return await res.json();
229
+ }
230
+
231
+ async listAdminUsers(sessionToken: string): Promise<Array<{ id: string; username: string; role: string; status: string; lastLoginAt?: number }>> {
232
+ const res = await fetch(`${this.url}/admin/users`, {
233
+ method: "POST",
234
+ headers: this.adminHeaders(sessionToken),
235
+ body: "{}",
236
+ });
237
+
238
+ if (!res.ok) {
239
+ await this.throwForError(res, "FAILED_TO_LIST_USERS");
240
+ }
241
+
242
+ return await res.json();
243
+ }
244
+
245
+ async createAdminUser(sessionToken: string, payload: { username: string; password: string; role: "admin" | "editor" | "viewer" }): Promise<{ id: string; username: string; role: string; status: string }> {
246
+ const res = await fetch(`${this.url}/admin/users/create`, {
247
+ method: "POST",
248
+ headers: this.adminHeaders(sessionToken),
249
+ body: JSON.stringify(payload),
250
+ });
251
+
252
+ if (res.status === 409) {
253
+ throw new HandoverError("USERNAME_TAKEN");
254
+ }
255
+
256
+ if (!res.ok) {
257
+ await this.throwForError(res, "FAILED_TO_CREATE_USER");
258
+ }
259
+
260
+ return await res.json();
261
+ }
262
+
263
+ async updateAdminUser(sessionToken: string, payload: { userId: string; role?: "owner" | "admin" | "editor" | "viewer"; status?: "active" | "disabled" | "locked" }): Promise<boolean> {
264
+ const res = await fetch(`${this.url}/admin/users/update`, {
265
+ method: "POST",
266
+ headers: this.adminHeaders(sessionToken),
267
+ body: JSON.stringify(payload),
268
+ });
269
+
270
+ if (!res.ok) {
271
+ await this.throwForError(res, "FAILED_TO_UPDATE_USER");
272
+ }
273
+
274
+ return true;
275
+ }
276
+
277
+ async deleteAdminUser(sessionToken: string, userId: string): Promise<boolean> {
278
+ const res = await fetch(`${this.url}/admin/users/delete`, {
279
+ method: "POST",
280
+ headers: this.adminHeaders(sessionToken),
281
+ body: JSON.stringify({ userId }),
282
+ });
283
+
284
+ if (res.status === 409) {
285
+ throw new HandoverError("CANNOT_DELETE_OWNER");
286
+ }
287
+
288
+ if (!res.ok) {
289
+ await this.throwForError(res, "FAILED_TO_DELETE_USER");
290
+ }
291
+
292
+ return true;
293
+ }
294
+
295
+ async resetAdminUserPassword(sessionToken: string, payload: { userId: string; newPassword: string }): Promise<boolean> {
296
+ const res = await fetch(`${this.url}/admin/users/reset_password`, {
297
+ method: "POST",
298
+ headers: this.adminHeaders(sessionToken),
299
+ body: JSON.stringify(payload),
300
+ });
301
+
302
+ if (!res.ok) {
303
+ await this.throwForError(res, "FAILED_TO_RESET_USER_PASSWORD");
304
+ }
305
+
306
+ return true;
307
+ }
308
+
309
+ async createAdminUserResetToken(sessionToken: string, userId: string): Promise<{ token: string; expiresAt: number }> {
310
+ const res = await fetch(`${this.url}/admin/users/create_reset_token`, {
311
+ method: "POST",
312
+ headers: this.adminHeaders(sessionToken),
313
+ body: JSON.stringify({ userId }),
314
+ });
315
+
316
+ if (!res.ok) {
317
+ await this.throwForError(res, "FAILED_TO_CREATE_RESET_TOKEN");
318
+ }
319
+
320
+ return await res.json();
321
+ }
322
+
323
+ async resetAdminPasswordWithToken(resetToken: string, newPassword: string): Promise<boolean> {
324
+ const res = await fetch(`${this.url}/admin/reset_with_token`, {
325
+ method: "POST",
326
+ headers: this.jsonHeaders(),
327
+ body: JSON.stringify({ resetToken, newPassword }),
328
+ });
329
+
330
+ if (res.status === 410) {
331
+ throw new HandoverError("RESET_TOKEN_EXPIRED");
332
+ }
333
+
334
+ if (res.status === 409) {
335
+ throw new HandoverError("RESET_TOKEN_USED");
336
+ }
337
+
338
+ if (res.status === 400) {
339
+ throw new HandoverError("INVALID_RESET_TOKEN");
340
+ }
341
+
342
+ if (!res.ok) {
343
+ await this.throwForError(res, "FAILED_TO_RESET_WITH_TOKEN");
344
+ }
345
+
346
+ return true;
347
+ }
348
+
349
+ async uploadImage(file: File, sessionToken: string, altText?: string): Promise<{ imageId: string, url: string }> {
350
+ const urlRes = await fetch(`${this.url}/storage/upload_url`, {
351
+ method: "POST",
352
+ headers: this.adminHeaders(sessionToken),
353
+ body: "{}",
354
+ });
355
+
356
+ if (!urlRes.ok) {
357
+ await this.throwForError(urlRes, "FAILED_TO_GET_UPLOAD_URL");
358
+ }
359
+
360
+ const { url: uploadUrl } = await urlRes.json();
361
+
362
+ const uploadRes = await fetch(uploadUrl, {
363
+ method: "POST",
364
+ headers: { "Content-Type": file.type },
365
+ body: file,
366
+ });
367
+
368
+ if (!uploadRes.ok) {
369
+ throw new HandoverError("FAILED_TO_UPLOAD_FILE");
370
+ }
371
+
372
+ const { storageId } = await uploadRes.json();
373
+
374
+ const saveRes = await fetch(`${this.url}/storage/save`, {
375
+ method: "POST",
376
+ headers: this.adminHeaders(sessionToken),
377
+ body: JSON.stringify({
378
+ storageId,
379
+ altText,
380
+ }),
381
+ });
382
+
383
+ if (!saveRes.ok) {
384
+ await this.throwForError(saveRes, "FAILED_TO_SAVE_IMAGE");
385
+ }
386
+
387
+ return await saveRes.json();
388
+ }
389
+ }