@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.
- package/.env.example +9 -1
- package/app/components/actions/case-export/download-handlers.ts +130 -62
- package/app/components/actions/case-manage/archive-package-builder.ts +299 -0
- package/app/components/actions/case-manage/delete-helpers.ts +61 -0
- package/app/components/actions/case-manage/index.ts +2 -0
- package/app/components/actions/case-manage/operations.ts +714 -0
- package/app/components/actions/case-manage/types.ts +21 -0
- package/app/components/actions/case-manage/utils.ts +34 -0
- package/app/components/actions/case-manage.ts +1 -1079
- package/app/components/navbar/case-import/case-import.module.css +2 -2
- package/app/components/navbar/case-import/case-import.tsx +0 -8
- package/app/components/navbar/case-import/components/CasePreviewSection.tsx +1 -1
- package/app/components/navbar/case-modals/all-cases-modal.tsx +13 -1
- package/app/components/navbar/navbar.tsx +8 -5
- package/app/components/sidebar/cases/case-sidebar.tsx +3 -2
- package/app/routes/auth/login.example.tsx +17 -5
- package/app/routes/striae/striae.tsx +36 -11
- package/app/types/export.ts +1 -0
- package/app/utils/forensics/SHA256.ts +2 -2
- package/app/utils/forensics/audit-export-signature.ts +1 -1
- package/app/utils/forensics/confirmation-signature.ts +1 -1
- package/app/utils/forensics/signature-utils.ts +7 -2
- package/functions/api/_shared/registration-allowlist.ts +38 -0
- package/functions/api/auth/can-register.ts +59 -0
- package/functions/api/user/[[path]].ts +34 -0
- package/members.emails.example +11 -0
- package/package.json +9 -9
- package/scripts/deploy-all.sh +2 -2
- package/scripts/deploy-members-emails.sh +102 -0
- package/scripts/deploy-pages-secrets.sh +13 -70
- package/scripts/deploy-primershear-emails.sh +7 -73
- package/worker-configuration.d.ts +2 -1
- package/workers/audit-worker/package.json +1 -5
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +1 -5
- package/workers/data-worker/src/signature-utils.ts +7 -2
- package/workers/data-worker/src/signing-payload-utils.ts +4 -4
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +1 -5
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -5
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +1 -5
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
|
@@ -433,7 +433,7 @@
|
|
|
433
433
|
}
|
|
434
434
|
|
|
435
435
|
.archivedImportNote {
|
|
436
|
-
margin
|
|
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
|
|
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}>
|
|
@@ -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
|
-
:
|
|
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
|
|
187
|
+
disabled={!hasLoadedCase || disableLongRunningCaseActions}
|
|
183
188
|
title={
|
|
184
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
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={
|
|
866
|
+
onOpenCaseExport={handleOpenCaseExport}
|
|
842
867
|
imageId={imageId}
|
|
843
868
|
currentCase={currentCase}
|
|
844
869
|
imageLoaded={imageLoaded}
|
package/app/types/export.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import { verifySignaturePayload } from './signature-utils';
|
|
8
8
|
|
|
9
|
-
export const FORENSIC_MANIFEST_VERSION = '
|
|
10
|
-
export const FORENSIC_MANIFEST_SIGNATURE_ALGORITHM = 'RSASSA-
|
|
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;
|
|
@@ -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: '
|
|
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
|
-
{
|
|
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": "
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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"
|
package/scripts/deploy-all.sh
CHANGED
|
@@ -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
|
|
131
|
-
if ! bash "$SCRIPT_DIR/deploy-pages-secrets.sh"
|
|
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
|