@striae-org/striae 3.3.0 → 4.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.
Files changed (53) hide show
  1. package/.env.example +1 -1
  2. package/app/components/actions/case-export/core-export.ts +5 -2
  3. package/app/components/actions/case-import/confirmation-import.ts +24 -23
  4. package/app/components/actions/case-import/image-operations.ts +20 -49
  5. package/app/components/actions/case-import/orchestrator.ts +1 -1
  6. package/app/components/actions/case-import/storage-operations.ts +54 -89
  7. package/app/components/actions/case-import/validation.ts +2 -13
  8. package/app/components/actions/case-manage.ts +15 -27
  9. package/app/components/actions/generate-pdf.ts +3 -7
  10. package/app/components/actions/image-manage.ts +63 -129
  11. package/app/components/button/button.module.css +12 -8
  12. package/app/components/sidebar/case-export/case-export.tsx +11 -6
  13. package/app/components/sidebar/cases/case-sidebar.tsx +21 -6
  14. package/app/components/sidebar/sidebar.module.css +0 -2
  15. package/app/components/user/delete-account.tsx +7 -7
  16. package/app/config-example/config.json +2 -8
  17. package/app/hooks/useInactivityTimeout.ts +2 -5
  18. package/app/root.tsx +94 -63
  19. package/app/routes/auth/login.tsx +3 -5
  20. package/app/routes/auth/route.ts +4 -3
  21. package/app/routes/striae/striae.tsx +4 -8
  22. package/app/services/audit/audit-api-client.ts +40 -0
  23. package/app/services/audit/audit-worker-client.ts +14 -17
  24. package/app/styles/root.module.css +13 -101
  25. package/app/tailwind.css +9 -2
  26. package/app/utils/auth.ts +5 -32
  27. package/app/utils/data-api-client.ts +43 -0
  28. package/app/utils/data-operations.ts +59 -75
  29. package/app/utils/image-api-client.ts +130 -0
  30. package/app/utils/pdf-api-client.ts +43 -0
  31. package/app/utils/permissions.ts +10 -23
  32. package/app/utils/user-api-client.ts +90 -0
  33. package/functions/api/_shared/firebase-auth.ts +255 -0
  34. package/functions/api/audit/[[path]].ts +150 -0
  35. package/functions/api/data/[[path]].ts +141 -0
  36. package/functions/api/image/[[path]].ts +127 -0
  37. package/functions/api/pdf/[[path]].ts +110 -0
  38. package/functions/api/user/[[path]].ts +196 -0
  39. package/package.json +2 -1
  40. package/scripts/deploy-all.sh +22 -8
  41. package/scripts/deploy-config.sh +143 -148
  42. package/scripts/deploy-pages-secrets.sh +231 -0
  43. package/scripts/deploy-worker-secrets.sh +1 -1
  44. package/workers/audit-worker/wrangler.jsonc.example +1 -8
  45. package/workers/data-worker/wrangler.jsonc.example +1 -8
  46. package/workers/image-worker/wrangler.jsonc.example +1 -8
  47. package/workers/keys-worker/wrangler.jsonc.example +2 -9
  48. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  49. package/workers/pdf-worker/wrangler.jsonc.example +1 -8
  50. package/workers/user-worker/src/user-worker.example.ts +121 -41
  51. package/workers/user-worker/wrangler.jsonc.example +1 -8
  52. package/wrangler.toml.example +1 -1
  53. package/app/styles/legal-pages.module.css +0 -113
@@ -6,8 +6,7 @@
6
6
 
7
7
  import type { User } from 'firebase/auth';
8
8
  import { type CaseData, type AnnotationData, type ConfirmationImportData } from '~/types';
9
- import paths from '~/config/config.json';
10
- import { getDataApiKey } from './auth';
9
+ import { fetchDataApi } from './data-api-client';
11
10
  import { validateUserSession, canAccessCase, canModifyCase } from './permissions';
12
11
  import {
13
12
  type ForensicManifestData,
@@ -21,8 +20,6 @@ import {
21
20
  isValidAuditExportSigningPayload
22
21
  } from './audit-export-signature';
23
22
 
24
- const DATA_WORKER_URL = paths.data_worker_url;
25
-
26
23
  // ============================================================================
27
24
  // INTERFACES AND TYPES
28
25
  // ============================================================================
@@ -101,15 +98,13 @@ export const getCaseData = async (
101
98
  throw new Error('Invalid case number provided');
102
99
  }
103
100
 
104
- const apiKey = await getDataApiKey();
105
- const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`;
106
-
107
- const response = await fetch(url, {
108
- method: 'GET',
109
- headers: {
110
- 'X-Custom-Auth-Key': apiKey
101
+ const response = await fetchDataApi(
102
+ user,
103
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
104
+ {
105
+ method: 'GET'
111
106
  }
112
- });
107
+ );
113
108
 
114
109
  if (response.status === 404) {
115
110
  return null; // Case not found
@@ -163,23 +158,23 @@ export const updateCaseData = async (
163
158
  throw new Error('Invalid case data provided');
164
159
  }
165
160
 
166
- const apiKey = await getDataApiKey();
167
- const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`;
168
-
169
161
  // Add timestamp if requested (default: true)
170
162
  const dataToSave = options.includeTimestamp !== false ? {
171
163
  ...caseData,
172
164
  updatedAt: new Date().toISOString()
173
165
  } : caseData;
174
166
 
175
- const response = await fetch(url, {
176
- method: 'PUT',
177
- headers: {
178
- 'Content-Type': 'application/json',
179
- 'X-Custom-Auth-Key': apiKey
180
- },
181
- body: JSON.stringify(dataToSave)
182
- });
167
+ const response = await fetchDataApi(
168
+ user,
169
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
170
+ {
171
+ method: 'PUT',
172
+ headers: {
173
+ 'Content-Type': 'application/json'
174
+ },
175
+ body: JSON.stringify(dataToSave)
176
+ }
177
+ );
183
178
 
184
179
  if (!response.ok) {
185
180
  throw new Error(`Failed to update case data: ${response.status} ${response.statusText}`);
@@ -216,15 +211,13 @@ export const deleteCaseData = async (
216
211
  }
217
212
  }
218
213
 
219
- const apiKey = await getDataApiKey();
220
- const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`;
221
-
222
- const response = await fetch(url, {
223
- method: 'DELETE',
224
- headers: {
225
- 'X-Custom-Auth-Key': apiKey
214
+ const response = await fetchDataApi(
215
+ user,
216
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
217
+ {
218
+ method: 'DELETE'
226
219
  }
227
- });
220
+ );
228
221
 
229
222
  if (!response.ok && response.status !== 404) {
230
223
  throw new Error(`Failed to delete case data: ${response.status} ${response.statusText}`);
@@ -269,15 +262,13 @@ export const getFileAnnotations = async (
269
262
  throw new Error('Invalid file ID provided');
270
263
  }
271
264
 
272
- const apiKey = await getDataApiKey();
273
- const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`;
274
-
275
- const response = await fetch(url, {
276
- method: 'GET',
277
- headers: {
278
- 'X-Custom-Auth-Key': apiKey
265
+ const response = await fetchDataApi(
266
+ user,
267
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
268
+ {
269
+ method: 'GET'
279
270
  }
280
- });
271
+ );
281
272
 
282
273
  if (response.status === 404) {
283
274
  return null; // No annotations found
@@ -334,16 +325,14 @@ export const saveFileAnnotations = async (
334
325
  throw new Error('Invalid annotation data provided');
335
326
  }
336
327
 
337
- const apiKey = await getDataApiKey();
338
- const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`;
339
-
340
328
  // Enforce immutability once confirmation data exists on an image.
341
- const existingResponse = await fetch(url, {
342
- method: 'GET',
343
- headers: {
344
- 'X-Custom-Auth-Key': apiKey
329
+ const existingResponse = await fetchDataApi(
330
+ user,
331
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
332
+ {
333
+ method: 'GET'
345
334
  }
346
- });
335
+ );
347
336
 
348
337
  if (existingResponse.ok) {
349
338
  const existingAnnotations = await existingResponse.json() as AnnotationData;
@@ -360,14 +349,17 @@ export const saveFileAnnotations = async (
360
349
  updatedAt: new Date().toISOString()
361
350
  };
362
351
 
363
- const response = await fetch(url, {
364
- method: 'PUT',
365
- headers: {
366
- 'Content-Type': 'application/json',
367
- 'X-Custom-Auth-Key': apiKey
368
- },
369
- body: JSON.stringify(dataToSave)
370
- });
352
+ const response = await fetchDataApi(
353
+ user,
354
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
355
+ {
356
+ method: 'PUT',
357
+ headers: {
358
+ 'Content-Type': 'application/json'
359
+ },
360
+ body: JSON.stringify(dataToSave)
361
+ }
362
+ );
371
363
 
372
364
  if (!response.ok) {
373
365
  throw new Error(`Failed to save file annotations: ${response.status} ${response.statusText}`);
@@ -407,15 +399,13 @@ export const deleteFileAnnotations = async (
407
399
  }
408
400
  }
409
401
 
410
- const apiKey = await getDataApiKey();
411
- const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`;
412
-
413
- const response = await fetch(url, {
414
- method: 'DELETE',
415
- headers: {
416
- 'X-Custom-Auth-Key': apiKey
402
+ const response = await fetchDataApi(
403
+ user,
404
+ `/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
405
+ {
406
+ method: 'DELETE'
417
407
  }
418
- });
408
+ );
419
409
 
420
410
  if (!response.ok && response.status !== 404) {
421
411
  throw new Error(`Failed to delete file annotations: ${response.status} ${response.statusText}`);
@@ -684,12 +674,10 @@ export const signForensicManifest = async (
684
674
  throw new Error(`Manifest signing denied: ${accessCheck.reason}`);
685
675
  }
686
676
 
687
- const apiKey = await getDataApiKey();
688
- const response = await fetch(`${DATA_WORKER_URL}/api/forensic/sign-manifest`, {
677
+ const response = await fetchDataApi(user, '/api/forensic/sign-manifest', {
689
678
  method: 'POST',
690
679
  headers: {
691
- 'Content-Type': 'application/json',
692
- 'X-Custom-Auth-Key': apiKey
680
+ 'Content-Type': 'application/json'
693
681
  },
694
682
  body: JSON.stringify({
695
683
  userId: user.uid,
@@ -752,12 +740,10 @@ export const signConfirmationData = async (
752
740
  throw new Error(`Confirmation signing denied: ${accessCheck.reason}`);
753
741
  }
754
742
 
755
- const apiKey = await getDataApiKey();
756
- const response = await fetch(`${DATA_WORKER_URL}/api/forensic/sign-confirmation`, {
743
+ const response = await fetchDataApi(user, '/api/forensic/sign-confirmation', {
757
744
  method: 'POST',
758
745
  headers: {
759
- 'Content-Type': 'application/json',
760
- 'X-Custom-Auth-Key': apiKey
746
+ 'Content-Type': 'application/json'
761
747
  },
762
748
  body: JSON.stringify({
763
749
  userId: user.uid,
@@ -828,12 +814,10 @@ export const signAuditExportData = async (
828
814
  }
829
815
  }
830
816
 
831
- const apiKey = await getDataApiKey();
832
- const response = await fetch(`${DATA_WORKER_URL}/api/forensic/sign-audit-export`, {
817
+ const response = await fetchDataApi(user, '/api/forensic/sign-audit-export', {
833
818
  method: 'POST',
834
819
  headers: {
835
- 'Content-Type': 'application/json',
836
- 'X-Custom-Auth-Key': apiKey
820
+ 'Content-Type': 'application/json'
837
821
  },
838
822
  body: JSON.stringify({
839
823
  userId: user.uid,
@@ -0,0 +1,130 @@
1
+ import type { User } from 'firebase/auth';
2
+ import { type ImageUploadResponse } from '~/types';
3
+
4
+ const IMAGE_API_BASE = '/api/image';
5
+
6
+ function normalizePath(path: string): string {
7
+ if (!path) {
8
+ return '/';
9
+ }
10
+
11
+ return path.startsWith('/') ? path : `/${path}`;
12
+ }
13
+
14
+ export async function fetchImageApi(
15
+ user: User,
16
+ path: string,
17
+ init: RequestInit = {}
18
+ ): Promise<Response> {
19
+ const normalizedPath = normalizePath(path);
20
+ const userWithOptionalToken = user as User & { getIdToken?: () => Promise<string> };
21
+
22
+ if (typeof userWithOptionalToken.getIdToken !== 'function') {
23
+ throw new Error('Unable to authenticate request: missing Firebase token provider');
24
+ }
25
+
26
+ let idToken: string;
27
+ try {
28
+ idToken = await userWithOptionalToken.getIdToken();
29
+ } catch {
30
+ throw new Error('Unable to authenticate request: failed to retrieve Firebase token');
31
+ }
32
+
33
+ if (!idToken) {
34
+ throw new Error('Unable to authenticate request: empty Firebase token');
35
+ }
36
+
37
+ const headers = new Headers(init.headers);
38
+ headers.set('Authorization', `Bearer ${idToken}`);
39
+
40
+ return fetch(`${IMAGE_API_BASE}${normalizedPath}`, {
41
+ ...init,
42
+ headers
43
+ });
44
+ }
45
+
46
+ interface XhrUploadResult {
47
+ status: number;
48
+ responseText: string;
49
+ }
50
+
51
+ function uploadWithXhr(
52
+ targetUrl: string,
53
+ authorizationValue: string,
54
+ file: File,
55
+ onProgress?: (progress: number) => void
56
+ ): Promise<XhrUploadResult> {
57
+ return new Promise((resolve, reject) => {
58
+ const xhr = new XMLHttpRequest();
59
+ const formData = new FormData();
60
+ formData.append('file', file);
61
+
62
+ xhr.upload.addEventListener('progress', (event) => {
63
+ if (event.lengthComputable && onProgress) {
64
+ const progress = Math.round((event.loaded / event.total) * 100);
65
+ onProgress(progress);
66
+ }
67
+ });
68
+
69
+ xhr.addEventListener('load', () => {
70
+ resolve({
71
+ status: xhr.status,
72
+ responseText: xhr.responseText
73
+ });
74
+ });
75
+
76
+ xhr.addEventListener('error', () => {
77
+ reject(new Error('Upload failed'));
78
+ });
79
+
80
+ xhr.open('POST', targetUrl);
81
+ xhr.setRequestHeader('Authorization', authorizationValue);
82
+ xhr.send(formData);
83
+ });
84
+ }
85
+
86
+ function parseUploadResponse(payload: string): ImageUploadResponse {
87
+ const parsed = JSON.parse(payload) as ImageUploadResponse;
88
+ if (!parsed.success || !parsed.result?.id) {
89
+ const errorMessage = parsed.errors?.map((entry) => entry.message).join(', ') || 'Upload failed';
90
+ throw new Error(errorMessage);
91
+ }
92
+
93
+ return parsed;
94
+ }
95
+
96
+ export async function uploadImageApi(
97
+ user: User,
98
+ file: File,
99
+ onProgress?: (progress: number) => void
100
+ ): Promise<ImageUploadResponse> {
101
+ const userWithOptionalToken = user as User & { getIdToken?: () => Promise<string> };
102
+
103
+ if (typeof userWithOptionalToken.getIdToken !== 'function') {
104
+ throw new Error('Unable to authenticate upload: missing Firebase token provider');
105
+ }
106
+
107
+ let idToken: string;
108
+ try {
109
+ idToken = await userWithOptionalToken.getIdToken();
110
+ } catch {
111
+ throw new Error('Unable to authenticate upload: failed to retrieve Firebase token');
112
+ }
113
+
114
+ if (!idToken) {
115
+ throw new Error('Unable to authenticate upload: empty Firebase token');
116
+ }
117
+
118
+ const proxyUploadResult = await uploadWithXhr(
119
+ `${IMAGE_API_BASE}/`,
120
+ `Bearer ${idToken}`,
121
+ file,
122
+ onProgress
123
+ );
124
+
125
+ if (proxyUploadResult.status < 200 || proxyUploadResult.status >= 300) {
126
+ throw new Error(`Upload failed with status ${proxyUploadResult.status}`);
127
+ }
128
+
129
+ return parseUploadResponse(proxyUploadResult.responseText);
130
+ }
@@ -0,0 +1,43 @@
1
+ import type { User } from 'firebase/auth';
2
+
3
+ const PDF_API_BASE = '/api/pdf';
4
+
5
+ function normalizePath(path: string): string {
6
+ if (!path) {
7
+ return '/';
8
+ }
9
+
10
+ return path.startsWith('/') ? path : `/${path}`;
11
+ }
12
+
13
+ export async function fetchPdfApi(
14
+ user: User,
15
+ path: string,
16
+ init: RequestInit = {}
17
+ ): Promise<Response> {
18
+ const normalizedPath = normalizePath(path);
19
+ const userWithOptionalToken = user as User & { getIdToken?: () => Promise<string> };
20
+
21
+ if (typeof userWithOptionalToken.getIdToken !== 'function') {
22
+ throw new Error('Unable to authenticate request: missing Firebase token provider');
23
+ }
24
+
25
+ let idToken: string;
26
+ try {
27
+ idToken = await userWithOptionalToken.getIdToken();
28
+ } catch {
29
+ throw new Error('Unable to authenticate request: failed to retrieve Firebase token');
30
+ }
31
+
32
+ if (!idToken) {
33
+ throw new Error('Unable to authenticate request: empty Firebase token');
34
+ }
35
+
36
+ const headers = new Headers(init.headers);
37
+ headers.set('Authorization', `Bearer ${idToken}`);
38
+
39
+ return fetch(`${PDF_API_BASE}${normalizedPath}`, {
40
+ ...init,
41
+ headers
42
+ });
43
+ }
@@ -1,9 +1,8 @@
1
1
  import type { User } from 'firebase/auth';
2
2
  import type { UserData, ExtendedUserData, UserLimits, ReadOnlyCaseMetadata } from '~/types';
3
3
  import paths from '~/config/config.json';
4
- import { getUserApiKey } from './auth';
4
+ import { fetchUserApi } from './user-api-client';
5
5
 
6
- const USER_WORKER_URL = paths.user_worker_url;
7
6
  const MAX_CASES_REVIEW = paths.max_cases_review;
8
7
  const MAX_FILES_PER_CASE_REVIEW = paths.max_files_per_case_review;
9
8
 
@@ -32,12 +31,8 @@ export interface CaseMetadata {
32
31
  */
33
32
  export const getUserData = async (user: User): Promise<UserData | null> => {
34
33
  try {
35
- const apiKey = await getUserApiKey();
36
- const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}`, {
34
+ const response = await fetchUserApi(user, `/${encodeURIComponent(user.uid)}`, {
37
35
  method: 'GET',
38
- headers: {
39
- 'X-Custom-Auth-Key': apiKey
40
- }
41
36
  });
42
37
 
43
38
  if (response.ok) {
@@ -121,12 +116,10 @@ export const createUser = async (
121
116
  createdAt: new Date().toISOString()
122
117
  };
123
118
 
124
- const apiKey = await getUserApiKey();
125
- const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}`, {
119
+ const response = await fetchUserApi(user, `/${encodeURIComponent(user.uid)}`, {
126
120
  method: 'PUT',
127
121
  headers: {
128
- 'Content-Type': 'application/json',
129
- 'X-Custom-Auth-Key': apiKey
122
+ 'Content-Type': 'application/json'
130
123
  },
131
124
  body: JSON.stringify(userData)
132
125
  });
@@ -280,12 +273,10 @@ export const updateUserData = async (user: User, updates: Partial<UserData>): Pr
280
273
  };
281
274
 
282
275
  // Perform the update with API key management
283
- const apiKey = await getUserApiKey();
284
- const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}`, {
276
+ const response = await fetchUserApi(user, `/${encodeURIComponent(user.uid)}`, {
285
277
  method: 'PUT',
286
278
  headers: {
287
- 'Content-Type': 'application/json',
288
- 'X-Custom-Auth-Key': apiKey
279
+ 'Content-Type': 'application/json'
289
280
  },
290
281
  body: JSON.stringify(updatedUserData)
291
282
  });
@@ -489,12 +480,10 @@ export const addUserCase = async (user: User, caseData: CaseMetadata): Promise<v
489
480
  }
490
481
 
491
482
  // Use the dedicated /cases endpoint to add the case
492
- const apiKey = await getUserApiKey();
493
- const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}/cases`, {
483
+ const response = await fetchUserApi(user, `/${encodeURIComponent(user.uid)}/cases`, {
494
484
  method: 'PUT',
495
485
  headers: {
496
- 'Content-Type': 'application/json',
497
- 'X-Custom-Auth-Key': apiKey
486
+ 'Content-Type': 'application/json'
498
487
  },
499
488
  body: JSON.stringify({
500
489
  cases: [caseData]
@@ -536,12 +525,10 @@ export const removeUserCase = async (user: User, caseNumber: string): Promise<vo
536
525
  }
537
526
 
538
527
  // Use the dedicated /cases DELETE endpoint to remove the case
539
- const apiKey = await getUserApiKey();
540
- const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}/cases`, {
528
+ const response = await fetchUserApi(user, `/${encodeURIComponent(user.uid)}/cases`, {
541
529
  method: 'DELETE',
542
530
  headers: {
543
- 'Content-Type': 'application/json',
544
- 'X-Custom-Auth-Key': apiKey
531
+ 'Content-Type': 'application/json'
545
532
  },
546
533
  body: JSON.stringify({
547
534
  casesToDelete: [caseNumber]
@@ -0,0 +1,90 @@
1
+ import type { User } from 'firebase/auth';
2
+
3
+ const USER_API_BASE = '/api/user';
4
+ const USER_EXISTS_API_BASE = '/api/user/exists';
5
+
6
+ function normalizePath(path: string): string {
7
+ if (!path) {
8
+ return '/';
9
+ }
10
+
11
+ return path.startsWith('/') ? path : `/${path}`;
12
+ }
13
+
14
+ export async function fetchUserApi(
15
+ user: User,
16
+ path: string,
17
+ init: RequestInit = {}
18
+ ): Promise<Response> {
19
+ const normalizedPath = normalizePath(path);
20
+ const userWithOptionalToken = user as User & { getIdToken?: () => Promise<string> };
21
+
22
+ if (typeof userWithOptionalToken.getIdToken !== 'function') {
23
+ throw new Error('Unable to authenticate request: missing Firebase token provider');
24
+ }
25
+
26
+ let idToken: string;
27
+ try {
28
+ idToken = await userWithOptionalToken.getIdToken();
29
+ } catch {
30
+ throw new Error('Unable to authenticate request: failed to retrieve Firebase token');
31
+ }
32
+
33
+ if (!idToken) {
34
+ throw new Error('Unable to authenticate request: empty Firebase token');
35
+ }
36
+
37
+ const headers = new Headers(init.headers);
38
+ headers.set('Authorization', `Bearer ${idToken}`);
39
+
40
+ return fetch(`${USER_API_BASE}${normalizedPath}`, {
41
+ ...init,
42
+ headers
43
+ });
44
+ }
45
+
46
+ export async function checkUserExistsApi(user: User, targetUid: string): Promise<boolean> {
47
+ const encodedTargetUid = encodeURIComponent(targetUid);
48
+ const userWithOptionalToken = user as User & { getIdToken?: () => Promise<string> };
49
+
50
+ if (typeof userWithOptionalToken.getIdToken !== 'function') {
51
+ throw new Error('Unable to authenticate request: missing Firebase token provider');
52
+ }
53
+
54
+ let idToken: string;
55
+ try {
56
+ idToken = await userWithOptionalToken.getIdToken();
57
+ } catch {
58
+ throw new Error('Unable to authenticate request: failed to retrieve Firebase token');
59
+ }
60
+
61
+ if (!idToken) {
62
+ throw new Error('Unable to authenticate request: empty Firebase token');
63
+ }
64
+
65
+ const proxyResponse = await fetch(`${USER_EXISTS_API_BASE}/${encodedTargetUid}`, {
66
+ method: 'GET',
67
+ headers: {
68
+ 'Authorization': `Bearer ${idToken}`,
69
+ 'Accept': 'application/json'
70
+ }
71
+ });
72
+
73
+ if (proxyResponse.status === 404) {
74
+ return false;
75
+ }
76
+
77
+ if (!proxyResponse.ok) {
78
+ throw new Error(`Failed to verify user existence: ${proxyResponse.status}`);
79
+ }
80
+
81
+ const responseData = await proxyResponse.json().catch(() => null) as {
82
+ exists?: boolean;
83
+ } | null;
84
+
85
+ if (typeof responseData?.exists === 'boolean') {
86
+ return responseData.exists;
87
+ }
88
+
89
+ return true;
90
+ }