@striae-org/striae 5.5.1 → 6.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 (45) hide show
  1. package/.env.example +9 -1
  2. package/app/components/actions/case-export/download-handlers.ts +130 -62
  3. package/app/components/actions/case-manage/archive-package-builder.ts +299 -0
  4. package/app/components/actions/case-manage/delete-helpers.ts +61 -0
  5. package/app/components/actions/case-manage/index.ts +2 -0
  6. package/app/components/actions/case-manage/operations.ts +714 -0
  7. package/app/components/actions/case-manage/types.ts +21 -0
  8. package/app/components/actions/case-manage/utils.ts +34 -0
  9. package/app/components/actions/case-manage.ts +1 -1079
  10. package/app/components/navbar/case-import/case-import.module.css +2 -2
  11. package/app/components/navbar/case-import/case-import.tsx +0 -8
  12. package/app/components/navbar/case-import/components/CasePreviewSection.tsx +1 -1
  13. package/app/components/navbar/case-modals/all-cases-modal.tsx +13 -1
  14. package/app/components/navbar/navbar.tsx +8 -5
  15. package/app/components/sidebar/cases/case-sidebar.tsx +3 -2
  16. package/app/routes/auth/login.example.tsx +17 -5
  17. package/app/routes/striae/striae.tsx +36 -11
  18. package/app/types/export.ts +1 -0
  19. package/app/utils/forensics/SHA256.ts +2 -2
  20. package/app/utils/forensics/audit-export-signature.ts +1 -1
  21. package/app/utils/forensics/confirmation-signature.ts +1 -1
  22. package/app/utils/forensics/signature-utils.ts +7 -2
  23. package/functions/api/_shared/registration-allowlist.ts +38 -0
  24. package/functions/api/auth/can-register.ts +59 -0
  25. package/functions/api/user/[[path]].ts +34 -0
  26. package/members.emails.example +11 -0
  27. package/package.json +9 -9
  28. package/scripts/deploy-all.sh +2 -2
  29. package/scripts/deploy-members-emails.sh +102 -0
  30. package/scripts/deploy-pages-secrets.sh +13 -70
  31. package/scripts/deploy-primershear-emails.sh +7 -73
  32. package/worker-configuration.d.ts +2 -1
  33. package/workers/audit-worker/package.json +1 -5
  34. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  35. package/workers/data-worker/package.json +1 -5
  36. package/workers/data-worker/src/signature-utils.ts +7 -2
  37. package/workers/data-worker/src/signing-payload-utils.ts +4 -4
  38. package/workers/data-worker/wrangler.jsonc.example +1 -1
  39. package/workers/image-worker/package.json +1 -5
  40. package/workers/image-worker/wrangler.jsonc.example +1 -1
  41. package/workers/pdf-worker/package.json +1 -5
  42. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  43. package/workers/user-worker/package.json +1 -5
  44. package/workers/user-worker/wrangler.jsonc.example +1 -1
  45. package/wrangler.toml.example +1 -1
@@ -433,7 +433,7 @@
433
433
  }
434
434
 
435
435
  .archivedImportNote {
436
- margin-bottom: var(--spaceM);
436
+ margin: var(--spaceM) 0;
437
437
  padding: var(--spaceS) var(--spaceM);
438
438
  border-radius: var(--spaceXS);
439
439
  background: color-mix(in lab, var(--success) 10%, transparent);
@@ -444,7 +444,7 @@
444
444
  }
445
445
 
446
446
  .archivedRegularCaseRiskNote {
447
- margin-bottom: var(--spaceM);
447
+ margin: var(--spaceM) 0;
448
448
  padding: var(--spaceS) var(--spaceM);
449
449
  border-radius: var(--spaceXS);
450
450
  background: color-mix(in lab, var(--warning) 12%, transparent);
@@ -239,7 +239,6 @@ export const CaseImport = ({
239
239
  if (!user || !importState.selectedFile || !importState.importType) return;
240
240
 
241
241
  if (importState.importType === 'case' && isArchivedRegularCaseImportBlocked) {
242
- setError(ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE);
243
242
  return;
244
243
  }
245
244
 
@@ -260,7 +259,6 @@ export const CaseImport = ({
260
259
  casePreview,
261
260
  updateImportState,
262
261
  executeImport,
263
- setError,
264
262
  ]);
265
263
 
266
264
  const handleCancelImport = useCallback(() => {
@@ -333,7 +331,6 @@ export const CaseImport = ({
333
331
  // Handle confirmation import
334
332
  const handleConfirmImport = useCallback(() => {
335
333
  if (isArchivedRegularCaseImportBlocked) {
336
- setError(ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE);
337
334
  return;
338
335
  }
339
336
 
@@ -343,7 +340,6 @@ export const CaseImport = ({
343
340
  isArchivedRegularCaseImportBlocked,
344
341
  executeImport,
345
342
  updateImportState,
346
- setError,
347
343
  ]);
348
344
 
349
345
  if (!isOpen) return null;
@@ -414,10 +410,6 @@ export const CaseImport = ({
414
410
  {/* Import progress */}
415
411
  <ProgressSection importProgress={importProgress} />
416
412
 
417
- {isArchivedRegularCaseImportBlocked && (
418
- <div className={styles.error}>{ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE}</div>
419
- )}
420
-
421
413
  {/* Success message */}
422
414
  {messages.success && (
423
415
  <div className={styles.success}>
@@ -82,7 +82,7 @@ export const CasePreviewSection = ({
82
82
  </div>
83
83
  )}
84
84
  </div>
85
- {casePreview.archived && (
85
+ {casePreview.archived && !isArchivedRegularCaseImportBlocked && (
86
86
  <div className={styles.archivedImportNote}>
87
87
  {ARCHIVED_SELF_IMPORT_NOTE}
88
88
  </div>
@@ -225,6 +225,14 @@ export const CasesModal = ({
225
225
  selectedCase && selectedCase.caseNumber !== currentCase && !selectedCase.isReadOnly
226
226
  );
227
227
 
228
+ const deleteSelectedCaseTitle = !selectedCase
229
+ ? 'Select a case to delete.'
230
+ : selectedCase.caseNumber === currentCase
231
+ ? 'Open a different case before deleting this one.'
232
+ : selectedCase.isReadOnly
233
+ ? 'Read-only review cases cannot be deleted. Use Clear RO Case under Case Management first.'
234
+ : undefined;
235
+
228
236
  useEffect(() => {
229
237
  setCurrentPage(0);
230
238
  }, [preferences.sortBy, preferences.confirmationFilter, preferences.showArchivedOnly]);
@@ -484,12 +492,15 @@ export const CasesModal = ({
484
492
  const handleDeleteSelectedCase = async () => {
485
493
  if (!selectedCase || !canDeleteSelectedCase) {
486
494
  const isCurrentCaseSelection = selectedCase?.caseNumber === currentCase;
495
+ const isReadOnlyReviewSelection = selectedCase?.isReadOnly === true;
487
496
 
488
497
  setActionNotice({
489
498
  type: 'warning',
490
499
  message: isCurrentCaseSelection
491
500
  ? 'Open a different case before deleting this one.'
492
- : 'Selected case cannot be deleted.',
501
+ : isReadOnlyReviewSelection
502
+ ? 'Read-only review cases cannot be deleted. Use Clear RO Case under Case Management first.'
503
+ : 'Selected case cannot be deleted.',
493
504
  });
494
505
  return;
495
506
  }
@@ -785,6 +796,7 @@ export const CasesModal = ({
785
796
  className={`${styles.secondaryActionButton} ${styles.deleteActionButton}`}
786
797
  onClick={handleDeleteSelectedCase}
787
798
  disabled={!canDeleteSelectedCase || isRunningAction}
799
+ title={deleteSelectedCaseTitle}
788
800
  >
789
801
  Delete Selected
790
802
  </button>
@@ -121,6 +121,11 @@ export const Navbar = ({
121
121
  const isImageNotesActive = canOpenImageNotes;
122
122
  const canDeleteCurrentFile = hasLoadedImage && !isReadOnly;
123
123
  const isArchivedRegularReadOnly = Boolean(isReadOnly && archiveDetails?.archived && !isReviewOnlyCase);
124
+ const caseExportLabel = isArchivedRegularReadOnly
125
+ ? 'Export Archive'
126
+ : isReadOnly
127
+ ? 'Export Confirmations'
128
+ : 'Export Case Package';
124
129
 
125
130
  return (
126
131
  <>
@@ -179,11 +184,9 @@ export const Navbar = ({
179
184
  type="button"
180
185
  role="menuitem"
181
186
  className={`${styles.caseMenuItem} ${styles.caseMenuItemExport}`}
182
- disabled={!hasLoadedCase || disableLongRunningCaseActions || isArchivedRegularReadOnly}
187
+ disabled={!hasLoadedCase || disableLongRunningCaseActions}
183
188
  title={
184
- isArchivedRegularReadOnly
185
- ? 'Export is unavailable for archived cases loaded from your regular case list'
186
- : !hasLoadedCase
189
+ !hasLoadedCase
187
190
  ? 'Load a case to export case data'
188
191
  : disableLongRunningCaseActions
189
192
  ? 'Export is unavailable while files are uploading'
@@ -194,7 +197,7 @@ export const Navbar = ({
194
197
  setIsCaseMenuOpen(false);
195
198
  }}
196
199
  >
197
- {isReadOnly ? 'Export Confirmations' : 'Export Case Package'}
200
+ {caseExportLabel}
198
201
  </button>
199
202
  <button
200
203
  type="button"
@@ -258,7 +258,8 @@ const handleImageSelect = (file: FileData) => {
258
258
  ? 'Select an image first'
259
259
  : undefined;
260
260
 
261
- const showCaseExportButton = Boolean(currentCase && isReadOnly && !isArchivedCase);
261
+ const showCaseExportButton = Boolean(currentCase && isReadOnly);
262
+ const caseExportButtonLabel = isArchivedCase ? 'Export Archive' : 'Export Confirmations';
262
263
 
263
264
  const exportCaseTitle = isUploading
264
265
  ? 'Cannot export while uploading'
@@ -391,7 +392,7 @@ return (
391
392
  disabled={isUploading || !currentCase}
392
393
  title={exportCaseTitle}
393
394
  >
394
- Export Confirmations
395
+ {caseExportButtonLabel}
395
396
  </button>
396
397
  ) : (
397
398
  <button
@@ -94,14 +94,26 @@ export const Login = () => {
94
94
  setIsClient(true);
95
95
  }, []);
96
96
 
97
- // Email validation with regex
98
- const validateRegistrationEmail = (email: string): { valid: boolean } => {
97
+ // Email validation with regex and registration gateway allowlist check
98
+ const validateRegistrationEmail = async (email: string): Promise<{ valid: boolean; message?: string }> => {
99
99
  const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
100
-
100
+
101
101
  if (!emailRegex.test(email)) {
102
102
  return { valid: false };
103
103
  }
104
104
 
105
+ try {
106
+ const response = await fetch(`/api/auth/can-register?email=${encodeURIComponent(email)}`);
107
+ if (response.status === 403) {
108
+ return {
109
+ valid: false,
110
+ message: 'Registration is limited to Striae membership. You may join at https://join.striae.org.'
111
+ };
112
+ }
113
+ } catch {
114
+ // Fail open on network error — server-side PUT guard provides defense-in-depth
115
+ }
116
+
105
117
  return { valid: true };
106
118
  };
107
119
 
@@ -282,9 +294,9 @@ export const Login = () => {
282
294
 
283
295
  try {
284
296
  if (!isLogin) {
285
- const emailValidation = validateRegistrationEmail(email);
297
+ const emailValidation = await validateRegistrationEmail(email);
286
298
  if (!emailValidation.valid) {
287
- setError('Please enter a valid email address');
299
+ setError(emailValidation.message ?? 'Please enter a valid email address');
288
300
  setIsLoading(false);
289
301
  return;
290
302
  }
@@ -19,7 +19,7 @@ import { FilesModal } from '~/components/sidebar/files/files-modal';
19
19
  import { NotesEditorModal } from '~/components/sidebar/notes/notes-editor-modal';
20
20
  import { UserAuditViewer } from '~/components/audit/user-audit-viewer';
21
21
  import { fetchUserApi } from '~/utils/api';
22
- import { type AnnotationData, type FileData } from '~/types';
22
+ import { type AnnotationData, type FileData, type ExportOptions } from '~/types';
23
23
  import { validateCaseNumber, renameCase, deleteCase, checkExistingCase, createNewCase, archiveCase, getCaseArchiveDetails } from '~/components/actions/case-manage';
24
24
  import { checkReadOnlyCaseExists, deleteReadOnlyCase } from '~/components/actions/case-review';
25
25
  import { canCreateCase, getCaseConfirmationSummary } from '~/utils/data';
@@ -77,15 +77,21 @@ export const Striae = ({ user }: StriaePage) => {
77
77
 
78
78
  // PDF generation states
79
79
  const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
80
+
81
+ // Toast notification states
80
82
  const [showToast, setShowToast] = useState(false);
81
83
  const [toastMessage, setToastMessage] = useState('');
82
84
  const [toastType, setToastType] = useState<ToastType>('success');
83
85
  const [toastDuration, setToastDuration] = useState(4000);
86
+
87
+ // Modal and sidebar states
84
88
  const [isAuditTrailOpen, setIsAuditTrailOpen] = useState(false);
85
89
  const [isRenameCaseModalOpen, setIsRenameCaseModalOpen] = useState(false);
86
90
  const [isOpenCaseModalOpen, setIsOpenCaseModalOpen] = useState(false);
87
91
  const [isListCasesModalOpen, setIsListCasesModalOpen] = useState(false);
88
92
  const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
93
+
94
+ // Case management action states
89
95
  const [isRenamingCase, setIsRenamingCase] = useState(false);
90
96
  const [isDeletingCase, setIsDeletingCase] = useState(false);
91
97
  const [isArchivingCase, setIsArchivingCase] = useState(false);
@@ -93,6 +99,8 @@ export const Striae = ({ user }: StriaePage) => {
93
99
  const [isOpeningCase, setIsOpeningCase] = useState(false);
94
100
  const [openCaseHelperText, setOpenCaseHelperText] = useState('');
95
101
  const [isArchiveCaseModalOpen, setIsArchiveCaseModalOpen] = useState(false);
102
+
103
+ // Export states
96
104
  const [isExportCaseModalOpen, setIsExportCaseModalOpen] = useState(false);
97
105
  const [isExportingCase, setIsExportingCase] = useState(false);
98
106
  const [isExportConfirmationsModalOpen, setIsExportConfirmationsModalOpen] = useState(false);
@@ -287,7 +295,8 @@ export const Striae = ({ user }: StriaePage) => {
287
295
  const handleExport = async (
288
296
  exportCaseNumber: string,
289
297
  designatedReviewerEmail?: string,
290
- onProgress?: (progress: number, label: string) => void
298
+ onProgress?: (progress: number, label: string) => void,
299
+ exportOptions?: ExportOptions
291
300
  ) => {
292
301
  if (!exportCaseNumber) {
293
302
  showNotification('Select a case before exporting.', 'error');
@@ -311,7 +320,7 @@ export const Striae = ({ user }: StriaePage) => {
311
320
  setShowToast(true);
312
321
  onProgress?.(roundedProgress, label);
313
322
  },
314
- { designatedReviewerEmail }
323
+ { ...exportOptions, designatedReviewerEmail }
315
324
  );
316
325
 
317
326
  showNotification(`Case ${exportCaseNumber} exported successfully.`, 'success');
@@ -364,6 +373,28 @@ export const Striae = ({ user }: StriaePage) => {
364
373
  }
365
374
  };
366
375
 
376
+ const handleOpenCaseExport = () => {
377
+ if (!currentCase) {
378
+ return;
379
+ }
380
+
381
+ if (isReadOnlyCase) {
382
+ if (isReviewOnlyCase) {
383
+ void handleOpenExportConfirmationsModal();
384
+ return;
385
+ }
386
+
387
+ if (archiveDetails.archived) {
388
+ void handleExport(currentCase, undefined, undefined, {
389
+ archivePackageMode: true,
390
+ });
391
+ return;
392
+ }
393
+ }
394
+
395
+ setIsExportCaseModalOpen(true);
396
+ };
397
+
367
398
  const handleRenameCaseSubmit = async (newCaseName: string) => {
368
399
  if (!currentCase) {
369
400
  showNotification('Select a case before renaming.', 'error');
@@ -809,13 +840,7 @@ export const Striae = ({ user }: StriaePage) => {
809
840
  void handleOpenCaseModal();
810
841
  }}
811
842
  onOpenListAllCases={() => setIsListCasesModalOpen(true)}
812
- onOpenCaseExport={() => {
813
- if (isReadOnlyCase) {
814
- void handleOpenExportConfirmationsModal();
815
- } else {
816
- setIsExportCaseModalOpen(true);
817
- }
818
- }}
843
+ onOpenCaseExport={handleOpenCaseExport}
819
844
  onOpenAuditTrail={() => setIsAuditTrailOpen(true)}
820
845
  onOpenRenameCase={() => setIsRenameCaseModalOpen(true)}
821
846
  onDeleteCase={() => {
@@ -838,7 +863,7 @@ export const Striae = ({ user }: StriaePage) => {
838
863
  onOpenCase={() => {
839
864
  void handleOpenCaseModal();
840
865
  }}
841
- onOpenCaseExport={() => void handleOpenExportConfirmationsModal()}
866
+ onOpenCaseExport={handleOpenCaseExport}
842
867
  imageId={imageId}
843
868
  currentCase={currentCase}
844
869
  imageLoaded={imageLoaded}
@@ -5,4 +5,5 @@ export interface ExportOptions {
5
5
  includeUserInfo?: boolean;
6
6
  protectForensicData?: boolean;
7
7
  designatedReviewerEmail?: string;
8
+ archivePackageMode?: boolean;
8
9
  }
@@ -6,8 +6,8 @@
6
6
 
7
7
  import { verifySignaturePayload } from './signature-utils';
8
8
 
9
- export const FORENSIC_MANIFEST_VERSION = '2.0';
10
- export const FORENSIC_MANIFEST_SIGNATURE_ALGORITHM = 'RSASSA-PKCS1-v1_5-SHA-256';
9
+ export const FORENSIC_MANIFEST_VERSION = '3.0';
10
+ export const FORENSIC_MANIFEST_SIGNATURE_ALGORITHM = 'RSASSA-PSS-SHA-256';
11
11
 
12
12
  export interface ForensicManifestData {
13
13
  dataHash: string;
@@ -5,7 +5,7 @@ import {
5
5
  } from './SHA256';
6
6
  import { verifySignaturePayload } from './signature-utils';
7
7
 
8
- export const AUDIT_EXPORT_SIGNATURE_VERSION = '1.0';
8
+ export const AUDIT_EXPORT_SIGNATURE_VERSION = '2.0';
9
9
 
10
10
  const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/i;
11
11
 
@@ -6,7 +6,7 @@ import {
6
6
  } from './SHA256';
7
7
  import { verifySignaturePayload } from './signature-utils';
8
8
 
9
- export const CONFIRMATION_SIGNATURE_VERSION = '2.0';
9
+ export const CONFIRMATION_SIGNATURE_VERSION = '3.0';
10
10
 
11
11
  const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/i;
12
12
 
@@ -29,6 +29,8 @@ export interface PublicSigningKeyDetails {
29
29
  publicKeyPem: string | null;
30
30
  }
31
31
 
32
+ const RSA_PSS_SALT_LENGTH = 32;
33
+
32
34
  type ManifestSigningConfig = {
33
35
  manifest_signing_public_keys?: Record<string, string>;
34
36
  manifest_signing_public_key?: string;
@@ -197,7 +199,7 @@ export async function verifySignaturePayload(
197
199
  'spki',
198
200
  publicKeyPemToArrayBuffer(publicKeyPem, invalidPublicKeyError),
199
201
  {
200
- name: 'RSASSA-PKCS1-v1_5',
202
+ name: 'RSA-PSS',
201
203
  hash: 'SHA-256'
202
204
  },
203
205
  false,
@@ -209,7 +211,10 @@ export async function verifySignaturePayload(
209
211
  signatureBuffer.set(signatureBytes);
210
212
 
211
213
  const verified = await crypto.subtle.verify(
212
- { name: 'RSASSA-PKCS1-v1_5' },
214
+ {
215
+ name: 'RSA-PSS',
216
+ saltLength: RSA_PSS_SALT_LENGTH
217
+ },
213
218
  key,
214
219
  signatureBuffer,
215
220
  new TextEncoder().encode(payload)
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Checks whether the given email is permitted to register based on the
3
+ * REGISTRATION_EMAILS secret (a comma-separated list of allowed entries).
4
+ *
5
+ * Each entry may be:
6
+ * - An exact email address: user@example.com
7
+ * - A domain wildcard: @example.com (matches any email from that domain)
8
+ *
9
+ * If registrationEmails is empty or unset, all registrations are allowed
10
+ * (backward-compatible — deploys without a members.emails file are unrestricted).
11
+ */
12
+ export function isEmailAllowed(email: string, registrationEmails: string): boolean {
13
+ if (!registrationEmails || registrationEmails.trim().length === 0) {
14
+ return true;
15
+ }
16
+
17
+ const normalizedEmail = email.toLowerCase().trim();
18
+ const entries = registrationEmails
19
+ .split(',')
20
+ .map(e => e.trim().toLowerCase())
21
+ .filter(Boolean);
22
+
23
+ for (const entry of entries) {
24
+ if (entry.startsWith('@')) {
25
+ // Domain wildcard: @example.com matches user@example.com
26
+ if (normalizedEmail.endsWith(entry)) {
27
+ return true;
28
+ }
29
+ } else {
30
+ // Exact email match
31
+ if (normalizedEmail === entry) {
32
+ return true;
33
+ }
34
+ }
35
+ }
36
+
37
+ return false;
38
+ }
@@ -0,0 +1,59 @@
1
+ import { isEmailAllowed } from '../_shared/registration-allowlist';
2
+
3
+ interface CanRegisterContext {
4
+ request: Request;
5
+ env: Env;
6
+ }
7
+
8
+ const SUPPORTED_METHODS = new Set(['GET', 'OPTIONS']);
9
+
10
+ function jsonResponse(payload: Record<string, unknown>, status: number = 200): Response {
11
+ return new Response(JSON.stringify(payload), {
12
+ status,
13
+ headers: {
14
+ 'Cache-Control': 'no-store',
15
+ 'Content-Type': 'application/json; charset=utf-8'
16
+ }
17
+ });
18
+ }
19
+
20
+ function textResponse(message: string, status: number): Response {
21
+ return new Response(message, {
22
+ status,
23
+ headers: {
24
+ 'Cache-Control': 'no-store',
25
+ 'Content-Type': 'text/plain; charset=utf-8'
26
+ }
27
+ });
28
+ }
29
+
30
+ export const onRequest = async ({ request, env }: CanRegisterContext): Promise<Response> => {
31
+ if (!SUPPORTED_METHODS.has(request.method)) {
32
+ return textResponse('Method not allowed', 405);
33
+ }
34
+
35
+ if (request.method === 'OPTIONS') {
36
+ return new Response(null, {
37
+ status: 204,
38
+ headers: {
39
+ 'Allow': 'GET, OPTIONS',
40
+ 'Cache-Control': 'no-store'
41
+ }
42
+ });
43
+ }
44
+
45
+ const url = new URL(request.url);
46
+ const email = url.searchParams.get('email');
47
+
48
+ if (!email || email.trim().length === 0) {
49
+ return textResponse('Missing required parameter: email', 400);
50
+ }
51
+
52
+ const registrationEmails = env.REGISTRATION_EMAILS ?? '';
53
+
54
+ if (isEmailAllowed(email, registrationEmails)) {
55
+ return jsonResponse({ allowed: true });
56
+ }
57
+
58
+ return jsonResponse({ allowed: false }, 403);
59
+ };
@@ -1,4 +1,5 @@
1
1
  import { verifyFirebaseIdentityFromRequest } from '../_shared/firebase-auth';
2
+ import { isEmailAllowed } from '../_shared/registration-allowlist';
2
3
 
3
4
  interface UserProxyContext {
4
5
  request: Request;
@@ -155,6 +156,39 @@ export const onRequest = async ({ request, env }: UserProxyContext): Promise<Res
155
156
  if (requestedUserId !== identity.uid) {
156
157
  return textResponse('Forbidden', 403);
157
158
  }
159
+
160
+ // Registration gateway: for PUT requests, check if this is a new user creation.
161
+ // If REGISTRATION_EMAILS is set and the user record does not yet exist, enforce the allowlist.
162
+ // This is defense-in-depth — the primary check runs client-side in the login flow.
163
+ if (request.method === 'PUT' && env.REGISTRATION_EMAILS && env.REGISTRATION_EMAILS.trim().length > 0) {
164
+ try {
165
+ const existenceCheckUrl = `${userWorkerBaseUrl}/${encodeURIComponent(requestedUserId)}`;
166
+ const existenceResponse = await fetch(existenceCheckUrl, {
167
+ method: 'GET',
168
+ headers: {
169
+ 'Accept': 'application/json',
170
+ 'X-Custom-Auth-Key': env.USER_DB_AUTH
171
+ }
172
+ });
173
+
174
+ if (existenceResponse.status === 404) {
175
+ // User does not exist yet — this is a registration PUT.
176
+ // Enforce the email allowlist.
177
+ if (!isEmailAllowed(identity.email ?? '', env.REGISTRATION_EMAILS)) {
178
+ return textResponse('Registration is not permitted for this email address', 403);
179
+ }
180
+ } else if (!existenceResponse.ok) {
181
+ // Existence check failed (non-404, non-2xx response).
182
+ // Fail closed: reject the registration to prevent allowlist bypass during errors.
183
+ return textResponse('Unable to verify registration eligibility', 502);
184
+ }
185
+ // If user already exists (200), proceed normally.
186
+ } catch {
187
+ // Fail closed: on network error with allowlist active, reject the request.
188
+ return textResponse('Unable to verify registration eligibility', 502);
189
+ }
190
+ }
191
+
158
192
  const upstreamUrl = `${userWorkerBaseUrl}${proxyPath}${requestUrl.search}`;
159
193
 
160
194
  const upstreamHeaders = new Headers();
@@ -0,0 +1,11 @@
1
+ # Registration gateway - authorized email addresses
2
+ # One entry per line. Lines starting with # are ignored.
3
+ # This file is untracked. Run: npm run deploy-members to push changes.
4
+ #
5
+ # Supported formats:
6
+ # Exact email: analyst@organization.com
7
+ # Domain wildcard: @organization.com (allows all emails from that domain)
8
+ #
9
+ # Examples:
10
+ # analyst@organization.com
11
+ # @striae.org
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "5.5.1",
3
+ "version": "6.0.0",
4
4
  "private": false,
5
5
  "description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
6
6
  "license": "Apache-2.0",
@@ -52,11 +52,12 @@
52
52
  "workers/pdf-worker/src/formats/format-striae.ts",
53
53
  ".env.example",
54
54
  "primershear.emails.example",
55
+ "members.emails.example",
55
56
  "firebase.json",
56
57
  "tsconfig.json",
57
58
  "vite.config.ts",
58
59
  "/worker-configuration.d.ts",
59
- "wrangler.toml.example",
60
+ "wrangler.toml.example",
60
61
  "LICENSE"
61
62
  ],
62
63
  "sideEffects": false,
@@ -90,9 +91,10 @@
90
91
  "install-workers": "bash ./scripts/install-workers.sh",
91
92
  "deploy-workers": "npm run deploy-workers:audit && npm run deploy-workers:data && npm run deploy-workers:image && npm run deploy-workers:pdf && npm run deploy-workers:user",
92
93
  "deploy-workers:secrets": "bash ./scripts/deploy-worker-secrets.sh",
93
- "deploy-pages:secrets": "bash ./scripts/deploy-pages-secrets.sh --production-only",
94
+ "deploy-pages:secrets": "bash ./scripts/deploy-pages-secrets.sh",
94
95
  "deploy-pages": "bash ./scripts/deploy-pages.sh",
95
- "deploy-primershear": "bash ./scripts/deploy-primershear-emails.sh --production-only",
96
+ "deploy-primershear": "bash ./scripts/deploy-primershear-emails.sh",
97
+ "deploy-members": "bash ./scripts/deploy-members-emails.sh",
96
98
  "deploy-workers:audit": "cd workers/audit-worker && npm run deploy",
97
99
  "deploy-workers:data": "cd workers/data-worker && npm run deploy",
98
100
  "deploy-workers:image": "cd workers/image-worker && npm run deploy",
@@ -101,7 +103,7 @@
101
103
  },
102
104
  "dependencies": {
103
105
  "@react-router/cloudflare": "^7.14.0",
104
- "firebase": "^12.11.0",
106
+ "firebase": "^12.12.0",
105
107
  "isbot": "^5.1.37",
106
108
  "jszip": "^3.10.1",
107
109
  "qrcode": "^1.5.4",
@@ -123,7 +125,7 @@
123
125
  "eslint-plugin-jsx-a11y": "^6.10.2",
124
126
  "eslint-plugin-react": "^7.37.5",
125
127
  "eslint-plugin-react-hooks": "^7.0.1",
126
- "firebase-admin": "^13.7.0",
128
+ "firebase-admin": "^13.8.0",
127
129
  "modern-normalize": "^3.0.1",
128
130
  "typescript": "^5.9.3",
129
131
  "vite": "^7.3.2",
@@ -131,9 +133,7 @@
131
133
  "wrangler": "^4.81.1"
132
134
  },
133
135
  "overrides": {
134
- "@tootallnate/once": "3.0.1",
135
- "tar": "7.5.11",
136
- "undici": "7.24.1"
136
+ "@tootallnate/once": "3.0.1"
137
137
  },
138
138
  "engines": {
139
139
  "node": ">=20.19.0"
@@ -127,8 +127,8 @@ echo ""
127
127
  # Step 5: Deploy Pages Secrets
128
128
  echo -e "${PURPLE}Step 5/6: Deploying Pages Secrets${NC}"
129
129
  echo "----------------------------------"
130
- echo -e "${YELLOW}🔐 Deploying Pages environment variables to production only...${NC}"
131
- if ! bash "$SCRIPT_DIR/deploy-pages-secrets.sh" --production-only; then
130
+ echo -e "${YELLOW}🔐 Deploying Pages environment variables...${NC}"
131
+ if ! bash "$SCRIPT_DIR/deploy-pages-secrets.sh"; then
132
132
  echo -e "${RED}❌ Pages secrets deployment failed!${NC}"
133
133
  exit 1
134
134
  fi