@striae-org/striae 3.2.2 → 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 (82) 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-export/download-handlers.ts +51 -3
  4. package/app/components/actions/case-import/confirmation-import.ts +65 -40
  5. package/app/components/actions/case-import/confirmation-package.ts +86 -0
  6. package/app/components/actions/case-import/image-operations.ts +20 -49
  7. package/app/components/actions/case-import/index.ts +1 -0
  8. package/app/components/actions/case-import/orchestrator.ts +13 -3
  9. package/app/components/actions/case-import/storage-operations.ts +54 -89
  10. package/app/components/actions/case-import/validation.ts +7 -111
  11. package/app/components/actions/case-import/zip-processing.ts +44 -2
  12. package/app/components/actions/case-manage.ts +15 -27
  13. package/app/components/actions/confirm-export.ts +44 -13
  14. package/app/components/actions/generate-pdf.ts +3 -7
  15. package/app/components/actions/image-manage.ts +63 -129
  16. package/app/components/button/button.module.css +12 -8
  17. package/app/components/form/form-button.tsx +1 -1
  18. package/app/components/form/form.module.css +9 -0
  19. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
  20. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
  21. package/app/components/sidebar/case-export/case-export.tsx +13 -60
  22. package/app/components/sidebar/case-import/case-import.tsx +18 -6
  23. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +6 -4
  24. package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
  25. package/app/components/sidebar/cases/case-sidebar.tsx +122 -52
  26. package/app/components/sidebar/cases/cases.module.css +101 -18
  27. package/app/components/sidebar/notes/notes.module.css +33 -13
  28. package/app/components/sidebar/sidebar.module.css +0 -2
  29. package/app/components/user/delete-account.tsx +7 -7
  30. package/app/components/user/manage-profile.tsx +1 -1
  31. package/app/components/user/mfa-phone-update.tsx +15 -12
  32. package/app/config-example/config.json +2 -8
  33. package/app/hooks/useInactivityTimeout.ts +2 -5
  34. package/app/root.tsx +96 -65
  35. package/app/routes/auth/login.tsx +132 -11
  36. package/app/routes/auth/route.ts +4 -3
  37. package/app/routes/striae/striae.tsx +4 -8
  38. package/app/services/audit/audit-api-client.ts +40 -0
  39. package/app/services/audit/audit-worker-client.ts +14 -17
  40. package/app/styles/root.module.css +13 -101
  41. package/app/tailwind.css +9 -2
  42. package/app/utils/SHA256.ts +5 -1
  43. package/app/utils/auth.ts +5 -32
  44. package/app/utils/confirmation-signature.ts +5 -1
  45. package/app/utils/data-api-client.ts +43 -0
  46. package/app/utils/data-operations.ts +59 -75
  47. package/app/utils/export-verification.ts +353 -0
  48. package/app/utils/image-api-client.ts +130 -0
  49. package/app/utils/pdf-api-client.ts +43 -0
  50. package/app/utils/permissions.ts +10 -23
  51. package/app/utils/signature-utils.ts +74 -4
  52. package/app/utils/user-api-client.ts +90 -0
  53. package/functions/api/_shared/firebase-auth.ts +255 -0
  54. package/functions/api/audit/[[path]].ts +150 -0
  55. package/functions/api/data/[[path]].ts +141 -0
  56. package/functions/api/image/[[path]].ts +127 -0
  57. package/functions/api/pdf/[[path]].ts +110 -0
  58. package/functions/api/user/[[path]].ts +196 -0
  59. package/package.json +8 -4
  60. package/public/favicon.ico +0 -0
  61. package/public/icon-256.png +0 -0
  62. package/public/icon-512.png +0 -0
  63. package/public/manifest.json +39 -0
  64. package/public/shortcut.png +0 -0
  65. package/public/social-image.png +0 -0
  66. package/react-router.config.ts +5 -0
  67. package/scripts/deploy-all.sh +22 -8
  68. package/scripts/deploy-config.sh +143 -148
  69. package/scripts/deploy-pages-secrets.sh +231 -0
  70. package/scripts/deploy-worker-secrets.sh +1 -1
  71. package/workers/audit-worker/wrangler.jsonc.example +1 -8
  72. package/workers/data-worker/wrangler.jsonc.example +1 -8
  73. package/workers/image-worker/wrangler.jsonc.example +1 -8
  74. package/workers/keys-worker/wrangler.jsonc.example +2 -9
  75. package/workers/pdf-worker/scripts/generate-assets.js +94 -0
  76. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  77. package/workers/pdf-worker/wrangler.jsonc.example +1 -8
  78. package/workers/user-worker/src/user-worker.example.ts +121 -41
  79. package/workers/user-worker/wrangler.jsonc.example +1 -8
  80. package/wrangler.toml.example +1 -1
  81. package/app/styles/legal-pages.module.css +0 -113
  82. package/public/favicon.svg +0 -9
@@ -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]
@@ -20,6 +20,15 @@ export interface SignatureVerificationMessages {
20
20
  verificationFailedError?: string;
21
21
  }
22
22
 
23
+ export interface SignatureVerificationOptions {
24
+ verificationPublicKeyPem?: string;
25
+ }
26
+
27
+ export interface PublicSigningKeyDetails {
28
+ keyId: string | null;
29
+ publicKeyPem: string | null;
30
+ }
31
+
23
32
  type ManifestSigningConfig = {
24
33
  manifest_signing_public_keys?: Record<string, string>;
25
34
  manifest_signing_public_key?: string;
@@ -30,6 +39,63 @@ function normalizePemPublicKey(pem: string): string {
30
39
  return pem.replace(/\\n/g, '\n').trim();
31
40
  }
32
41
 
42
+ function normalizePemOrNull(pem: unknown): string | null {
43
+ if (typeof pem !== 'string' || pem.trim().length === 0) {
44
+ return null;
45
+ }
46
+
47
+ return normalizePemPublicKey(pem);
48
+ }
49
+
50
+ function sanitizeKeyIdForFileName(keyId: string): string {
51
+ return keyId.trim().replace(/[^a-z0-9_-]+/gi, '-');
52
+ }
53
+
54
+ export function createPublicSigningKeyFileName(keyId?: string | null): string {
55
+ if (typeof keyId === 'string' && keyId.trim().length > 0) {
56
+ return `striae-public-signing-key-${sanitizeKeyIdForFileName(keyId)}.pem`;
57
+ }
58
+
59
+ return 'striae-public-signing-key.pem';
60
+ }
61
+
62
+ export function getCurrentPublicSigningKeyDetails(): PublicSigningKeyDetails {
63
+ const config = paths as unknown as ManifestSigningConfig;
64
+ const configuredKeyId =
65
+ typeof config.manifest_signing_key_id === 'string' && config.manifest_signing_key_id.trim().length > 0
66
+ ? config.manifest_signing_key_id
67
+ : null;
68
+
69
+ if (configuredKeyId) {
70
+ const configuredKey = getVerificationPublicKey(configuredKeyId);
71
+ if (configuredKey) {
72
+ return {
73
+ keyId: configuredKeyId,
74
+ publicKeyPem: configuredKey
75
+ };
76
+ }
77
+ }
78
+
79
+ const keyMap = config.manifest_signing_public_keys;
80
+ if (keyMap && typeof keyMap === 'object') {
81
+ const firstConfiguredEntry = Object.entries(keyMap).find(
82
+ ([, value]) => typeof value === 'string' && value.trim().length > 0
83
+ );
84
+
85
+ if (firstConfiguredEntry) {
86
+ return {
87
+ keyId: firstConfiguredEntry[0],
88
+ publicKeyPem: normalizePemPublicKey(firstConfiguredEntry[1])
89
+ };
90
+ }
91
+ }
92
+
93
+ return {
94
+ keyId: null,
95
+ publicKeyPem: normalizePemOrNull(config.manifest_signing_public_key)
96
+ };
97
+ }
98
+
33
99
  function publicKeyPemToArrayBuffer(publicKeyPem: string, invalidPublicKeyError: string): ArrayBuffer {
34
100
  const normalized = normalizePemPublicKey(publicKeyPem);
35
101
  const pemBody = normalized
@@ -71,7 +137,7 @@ export function getVerificationPublicKey(keyId: string): string | null {
71
137
  if (keyMap && typeof keyMap === 'object') {
72
138
  const mappedKey = keyMap[keyId];
73
139
  if (typeof mappedKey === 'string' && mappedKey.trim().length > 0) {
74
- return mappedKey;
140
+ return normalizePemPublicKey(mappedKey);
75
141
  }
76
142
  }
77
143
 
@@ -81,7 +147,7 @@ export function getVerificationPublicKey(keyId: string): string | null {
81
147
  typeof config.manifest_signing_public_key === 'string' &&
82
148
  config.manifest_signing_public_key.trim().length > 0
83
149
  ) {
84
- return config.manifest_signing_public_key;
150
+ return normalizePemPublicKey(config.manifest_signing_public_key);
85
151
  }
86
152
 
87
153
  return null;
@@ -91,7 +157,8 @@ export async function verifySignaturePayload(
91
157
  payload: string,
92
158
  signature: SignatureEnvelope,
93
159
  expectedAlgorithm: string,
94
- messages: SignatureVerificationMessages = {}
160
+ messages: SignatureVerificationMessages = {},
161
+ options: SignatureVerificationOptions = {}
95
162
  ): Promise<SignatureVerificationResult> {
96
163
  if (signature.algorithm !== expectedAlgorithm) {
97
164
  return {
@@ -108,7 +175,10 @@ export async function verifySignaturePayload(
108
175
  };
109
176
  }
110
177
 
111
- const publicKeyPem = getVerificationPublicKey(signature.keyId);
178
+ const publicKeyPem =
179
+ typeof options.verificationPublicKeyPem === 'string' && options.verificationPublicKeyPem.trim().length > 0
180
+ ? options.verificationPublicKeyPem
181
+ : getVerificationPublicKey(signature.keyId);
112
182
  if (!publicKeyPem) {
113
183
  return {
114
184
  isValid: false,
@@ -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
+ }