@striae-org/striae 4.2.1 → 4.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/app/components/actions/case-import/confirmation-import.ts +20 -1
  2. package/app/components/actions/case-import/orchestrator.ts +3 -0
  3. package/app/components/actions/case-manage.ts +5 -1
  4. package/app/components/actions/confirm-export.ts +12 -3
  5. package/app/components/audit/viewer/audit-entries-list.tsx +20 -2
  6. package/app/components/audit/viewer/use-audit-viewer-export.ts +2 -2
  7. package/app/components/audit/viewer/use-audit-viewer-filters.ts +11 -1
  8. package/app/components/canvas/canvas.tsx +2 -1
  9. package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
  10. package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
  11. package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
  12. package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
  13. package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
  14. package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
  15. package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
  16. package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
  17. package/app/components/navbar/navbar.module.css +11 -0
  18. package/app/components/navbar/navbar.tsx +38 -19
  19. package/app/components/sidebar/case-import/hooks/useImportExecution.ts +2 -0
  20. package/app/components/sidebar/cases/case-sidebar.tsx +27 -3
  21. package/app/components/sidebar/cases/cases-modal.module.css +312 -10
  22. package/app/components/sidebar/cases/cases-modal.tsx +690 -110
  23. package/app/components/sidebar/cases/cases.module.css +23 -0
  24. package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
  25. package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
  26. package/app/components/sidebar/files/files-modal.module.css +285 -44
  27. package/app/components/sidebar/files/files-modal.tsx +452 -145
  28. package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
  29. package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
  30. package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
  31. package/app/components/sidebar/notes/class-details-shared.ts +239 -0
  32. package/app/components/sidebar/notes/notes-editor-form.tsx +43 -5
  33. package/app/components/sidebar/notes/notes.module.css +236 -4
  34. package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
  35. package/app/components/sidebar/sidebar-container.tsx +2 -0
  36. package/app/components/sidebar/sidebar.tsx +8 -1
  37. package/app/hooks/useCaseListPreferences.ts +99 -0
  38. package/app/hooks/useFileListPreferences.ts +106 -0
  39. package/app/routes/striae/striae.tsx +45 -1
  40. package/app/services/audit/audit-export-csv.ts +4 -2
  41. package/app/services/audit/audit-export-report.ts +36 -4
  42. package/app/services/audit/audit.service.ts +2 -0
  43. package/app/services/audit/builders/audit-entry-builder.ts +1 -0
  44. package/app/services/audit/builders/audit-event-builders-workflow.ts +8 -2
  45. package/app/types/annotations.ts +48 -1
  46. package/app/types/audit.ts +1 -0
  47. package/app/utils/data/case-filters.ts +127 -0
  48. package/app/utils/data/confirmation-summary/summary-core.ts +18 -2
  49. package/app/utils/data/file-filters.ts +201 -0
  50. package/app/utils/forensics/confirmation-signature.ts +20 -5
  51. package/functions/api/image/[[path]].ts +4 -0
  52. package/package.json +3 -4
  53. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  54. package/workers/data-worker/src/signing-payload-utils.ts +5 -0
  55. package/workers/data-worker/wrangler.jsonc.example +1 -1
  56. package/workers/image-worker/wrangler.jsonc.example +1 -1
  57. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  58. package/workers/pdf-worker/src/formats/format-striae.ts +84 -118
  59. package/workers/pdf-worker/src/pdf-worker.example.ts +28 -10
  60. package/workers/pdf-worker/src/report-layout.ts +227 -0
  61. package/workers/pdf-worker/src/report-types.ts +20 -0
  62. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  63. package/workers/user-worker/wrangler.jsonc.example +1 -1
  64. package/wrangler.toml.example +1 -1
  65. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  66. /package/workers/pdf-worker/src/assets/{generated-assets.ts → generated-assets.example.ts} +0 -0
@@ -123,8 +123,12 @@ function normalizeConfirmations(confirmations: ConfirmationMap): ConfirmationMap
123
123
 
124
124
  export function createConfirmationSigningPayload(
125
125
  confirmationData: ConfirmationImportData,
126
- signatureVersion: string = CONFIRMATION_SIGNATURE_VERSION
126
+ signatureVersion: string = CONFIRMATION_SIGNATURE_VERSION,
127
+ options: {
128
+ includeExportedByBadgeId?: boolean;
129
+ } = {}
127
130
  ): string {
131
+ const includeExportedByBadgeId = options.includeExportedByBadgeId !== false;
128
132
  const canonicalPayload = {
129
133
  signatureVersion,
130
134
  metadata: {
@@ -134,7 +138,7 @@ export function createConfirmationSigningPayload(
134
138
  exportedByUid: confirmationData.metadata.exportedByUid,
135
139
  exportedByName: confirmationData.metadata.exportedByName,
136
140
  exportedByCompany: confirmationData.metadata.exportedByCompany,
137
- ...(confirmationData.metadata.exportedByBadgeId
141
+ ...(includeExportedByBadgeId && confirmationData.metadata.exportedByBadgeId
138
142
  ? { exportedByBadgeId: confirmationData.metadata.exportedByBadgeId }
139
143
  : {}),
140
144
  totalConfirmations: confirmationData.metadata.totalConfirmations,
@@ -180,9 +184,7 @@ export async function verifyConfirmationSignature(
180
184
  };
181
185
  }
182
186
 
183
- const payload = createConfirmationSigningPayload(confirmationData, signatureVersion);
184
-
185
- return verifySignaturePayload(
187
+ const verifyPayload = (payload: string) => verifySignaturePayload(
186
188
  payload,
187
189
  signature,
188
190
  FORENSIC_MANIFEST_SIGNATURE_ALGORITHM,
@@ -197,4 +199,17 @@ export async function verifyConfirmationSignature(
197
199
  verificationPublicKeyPem
198
200
  }
199
201
  );
202
+
203
+ const primaryPayload = createConfirmationSigningPayload(confirmationData, signatureVersion);
204
+ const primaryResult = await verifyPayload(primaryPayload);
205
+
206
+ if (primaryResult.isValid || !confirmationData.metadata.exportedByBadgeId) {
207
+ return primaryResult;
208
+ }
209
+
210
+ const legacyPayload = createConfirmationSigningPayload(confirmationData, signatureVersion, {
211
+ includeExportedByBadgeId: false
212
+ });
213
+
214
+ return verifyPayload(legacyPayload);
200
215
  }
@@ -53,6 +53,10 @@ function extractProxyPath(url: URL): ProxyPathResult {
53
53
 
54
54
  try {
55
55
  const decodedPath = decodeURIComponent(encodedPath);
56
+ if (decodedPath.includes('?') || decodedPath.includes('#')) {
57
+ return { ok: false, reason: 'bad-encoding' };
58
+ }
59
+
56
60
  return { ok: true, path: decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}` };
57
61
  } catch {
58
62
  return { ok: false, reason: 'bad-encoding' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "4.2.1",
3
+ "version": "4.3.1",
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",
@@ -54,10 +54,9 @@
54
54
  "workers/*/src/*.example.ts",
55
55
  "workers/*/src/*.example.js",
56
56
  "workers/*/src/*.ts",
57
- "workers/pdf-worker/scripts/*.js",
58
- "workers/pdf-worker/src/assets/icon-256.png",
57
+ "workers/pdf-worker/scripts/*.js",
59
58
  "!workers/*/src/*worker.ts",
60
- "workers/pdf-worker/src/assets/generated-assets.ts",
59
+ "workers/pdf-worker/src/assets/generated-assets.example.ts",
61
60
  "workers/pdf-worker/src/formats/format-striae.ts",
62
61
  "workers/pdf-worker/src/report-types.ts",
63
62
  "workers/*/wrangler.jsonc.example",
@@ -2,7 +2,7 @@
2
2
  "name": "AUDIT_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/audit-worker.ts",
5
- "compatibility_date": "2026-03-21",
5
+ "compatibility_date": "2026-03-22",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -13,6 +13,7 @@ export interface ConfirmationSignatureMetadata {
13
13
  exportedByUid: string;
14
14
  exportedByName: string;
15
15
  exportedByCompany: string;
16
+ exportedByBadgeId?: string;
16
17
  totalConfirmations: number;
17
18
  version: string;
18
19
  hash: string;
@@ -131,6 +132,7 @@ export function isValidConfirmationPayload(
131
132
  typeof metadata.exportedByUid !== 'string' ||
132
133
  typeof metadata.exportedByName !== 'string' ||
133
134
  typeof metadata.exportedByCompany !== 'string' ||
135
+ (typeof metadata.exportedByBadgeId !== 'undefined' && typeof metadata.exportedByBadgeId !== 'string') ||
134
136
  typeof metadata.totalConfirmations !== 'number' ||
135
137
  metadata.totalConfirmations < 0 ||
136
138
  typeof metadata.version !== 'string' ||
@@ -261,6 +263,9 @@ export function createConfirmationSigningPayload(confirmationData: ConfirmationS
261
263
  exportedByUid: confirmationData.metadata.exportedByUid,
262
264
  exportedByName: confirmationData.metadata.exportedByName,
263
265
  exportedByCompany: confirmationData.metadata.exportedByCompany,
266
+ ...(confirmationData.metadata.exportedByBadgeId
267
+ ? { exportedByBadgeId: confirmationData.metadata.exportedByBadgeId }
268
+ : {}),
264
269
  totalConfirmations: confirmationData.metadata.totalConfirmations,
265
270
  version: confirmationData.metadata.version,
266
271
  hash: confirmationData.metadata.hash.toUpperCase(),
@@ -3,7 +3,7 @@
3
3
  "name": "DATA_WORKER_NAME",
4
4
  "account_id": "ACCOUNT_ID",
5
5
  "main": "src/data-worker.ts",
6
- "compatibility_date": "2026-03-21",
6
+ "compatibility_date": "2026-03-22",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -2,7 +2,7 @@
2
2
  "name": "IMAGES_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/image-worker.ts",
5
- "compatibility_date": "2026-03-21",
5
+ "compatibility_date": "2026-03-22",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -2,7 +2,7 @@
2
2
  "name": "KEYS_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/keys.ts",
5
- "compatibility_date": "2026-03-21",
5
+ "compatibility_date": "2026-03-22",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -1,9 +1,12 @@
1
- import type { PDFGenerationData, ReportRenderer } from '../report-types';
1
+ import type { PDFGenerationData, ReportPdfOptionsBuilder, ReportRenderer } from '../report-types';
2
2
  import { ICON_256 } from '../assets/generated-assets';
3
+ import { buildRepeatedChromePdfOptions, escapeHtml } from '../report-layout';
3
4
 
4
5
  export const renderReport: ReportRenderer = (data: PDFGenerationData): string => {
5
- const { imageUrl, caseNumber, annotationData, activeAnnotations, currentDate, notesUpdatedFormatted, userCompany } = data;
6
+ const { imageUrl, annotationData, activeAnnotations } = data;
6
7
  const annotationsSet = new Set(activeAnnotations);
8
+ const hasImage = Boolean(imageUrl && imageUrl !== '/clear.jpg');
9
+ const safeText = (value: unknown): string => escapeHtml(String(value ?? ''));
7
10
 
8
11
  // Programmatically determine if a color is dark and needs a light background
9
12
  const needsLightBackground = (color: string | undefined): boolean => {
@@ -71,42 +74,23 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
71
74
  <style>
72
75
  html, body {
73
76
  width: 100%;
74
- height: 100%;
75
77
  margin: 0;
76
78
  font-family: Arial, sans-serif;
77
79
  background-color: white;
78
- display: flex;
79
- flex-direction: column;
80
- min-height: 100vh;
81
80
  }
82
- .header {
83
- display: flex;
84
- align-items: center;
85
- margin-bottom: 15px;
86
- border-bottom: 2px solid #333;
87
- padding-bottom: 8px;
88
- position: relative;
89
- }
90
- .header-content {
91
- flex: 1;
92
- display: flex;
93
- align-items: center;
94
- justify-content: space-between;
81
+ body {
82
+ color: #333;
95
83
  }
96
- .date {
97
- font-size: 16px;
98
- font-weight: bold;
84
+ .report-body {
85
+ width: 100%;
86
+ box-sizing: border-box;
99
87
  }
100
- .case-number {
101
- font-size: 16px;
102
- font-weight: bold;
103
- color: #333;
104
- text-align: right;
105
- }
106
88
  .image-container {
107
89
  width: 100%;
108
- margin: 10px 0;
90
+ margin: 0 0 10px;
109
91
  position: relative;
92
+ page-break-inside: avoid;
93
+ break-inside: avoid;
110
94
  }
111
95
  .image-wrapper {
112
96
  display: flex;
@@ -170,6 +154,8 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
170
154
  width: 100%;
171
155
  margin-top: 12px;
172
156
  min-height: 50px;
157
+ page-break-inside: avoid;
158
+ break-inside: avoid;
173
159
  }
174
160
  .support-level-annotation {
175
161
  flex: 1;
@@ -235,10 +221,21 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
235
221
  }
236
222
  .confirmation-section {
237
223
  margin-top: 20px;
224
+ display: flex;
225
+ flex-direction: column;
226
+ gap: 16px;
227
+ }
228
+ .notes-page {
229
+ page-break-before: always;
230
+ break-before: page;
231
+ }
232
+ .confirmation-summary {
238
233
  display: flex;
239
234
  justify-content: flex-start;
240
235
  align-items: flex-start;
241
236
  gap: 20px;
237
+ page-break-inside: avoid;
238
+ break-inside: avoid;
242
239
  }
243
240
  .confirmation-box {
244
241
  background: #ffffff;
@@ -247,6 +244,7 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
247
244
  padding: 15px;
248
245
  width: 280px;
249
246
  font-family: 'Inter', Arial, sans-serif;
247
+ box-sizing: border-box;
250
248
  }
251
249
  .confirmation-label {
252
250
  font-size: 14px;
@@ -274,6 +272,7 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
274
272
  padding: 15px;
275
273
  width: 280px;
276
274
  font-family: 'Inter', Arial, sans-serif;
275
+ box-sizing: border-box;
277
276
  }
278
277
  .confirmation-title {
279
278
  font-size: 14px;
@@ -316,64 +315,32 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
316
315
  letter-spacing: 1px;
317
316
  }
318
317
  .additional-notes-section {
319
- max-width: 400px;
318
+ border: 1px solid #d7dbe0;
319
+ border-radius: 8px;
320
+ background: #fafbfc;
321
+ padding: 16px 18px;
322
+ box-sizing: border-box;
320
323
  font-family: 'Inter', Arial, sans-serif;
321
- font-size: 14px;
322
- line-height: 1.6;
323
324
  color: #333;
324
- white-space: pre-wrap;
325
- word-wrap: break-word;
326
- text-indent: 0 !important;
327
- padding: 0;
328
- margin: 0;
329
- margin-left: 20px;
330
- flex-shrink: 0;
331
- text-align: left;
332
- display: block;
333
325
  }
334
- .footer {
335
- margin-top: auto;
336
- padding-top: 15px;
337
- border-top: 1px solid #ccc;
338
- display: flex;
339
- justify-content: space-between;
340
- align-items: center;
341
- font-family: 'Inter', Arial, sans-serif;
342
- font-size: 11px;
326
+ .additional-notes-title {
327
+ margin: 0 0 10px;
328
+ font-size: 12px;
329
+ font-weight: 700;
330
+ letter-spacing: 0.08em;
331
+ text-transform: uppercase;
343
332
  color: #666;
344
333
  }
345
- .main-content {
346
- flex: 1;
347
- display: flex;
348
- flex-direction: column;
349
- }
350
- .content-wrapper {
351
- flex-grow: 0;
352
- flex-shrink: 0;
353
- }
354
- .footer-left {
355
- font-weight: 500;
356
- flex: 1;
357
- text-align: left;
358
- display: flex;
359
- align-items: center;
360
- gap: 6px;
361
- }
362
- .footer-brand-icon {
363
- width: 14px;
364
- height: 14px;
365
- object-fit: contain;
366
- }
367
- .footer-center {
368
- font-weight: 600;
369
- flex: 1;
370
- text-align: center;
371
- color: #333;
372
- }
373
- .footer-right {
374
- font-style: italic;
375
- flex: 1;
376
- text-align: right;
334
+ .additional-notes-body {
335
+ margin: 0;
336
+ font-size: 14px;
337
+ line-height: 1.6;
338
+ white-space: pre-wrap;
339
+ overflow-wrap: anywhere;
340
+ word-break: break-word;
341
+ text-indent: 0 !important;
342
+ orphans: 3;
343
+ widows: 3;
377
344
  }
378
345
  .index-section {
379
346
  text-align: center;
@@ -395,19 +362,12 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
395
362
  </style>
396
363
  </head>
397
364
  <body>
398
- <div class="main-content">
399
- <div class="content-wrapper">
400
- <div class="header">
401
- <div class="header-content">
402
- <div class="date">${currentDate}</div>
403
- ${caseNumber ? `<div class="case-number">${caseNumber}</div>` : '<div class="case-number"></div>'}
404
- </div>
405
- </div>
365
+ <div class="report-body">
406
366
 
407
- ${imageUrl && imageUrl !== '/clear.jpg' ? `
367
+ ${hasImage ? `
408
368
  ${annotationData && annotationsSet?.has('index') && annotationData.indexType === 'number' && annotationData.indexNumber ? `
409
369
  <div class="index-section">
410
- Index: ${annotationData.indexNumber}
370
+ Index: ${safeText(annotationData.indexNumber)}
411
371
  </div>
412
372
  ` : ''}
413
373
 
@@ -419,12 +379,12 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
419
379
  <div class="annotations-overlay">
420
380
  <div class="left-annotation" style="${needsLightBackground(annotationData.caseFontColor || '#FFDE21') ? 'background: rgba(255, 255, 255, 0.9); border: 2px solid rgba(0, 0, 0, 0.2); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);' : ''}">
421
381
  <div class="case-text" style="color: ${annotationData.caseFontColor || '#FFDE21'};">
422
- ${annotationData.leftCase}${annotationData.leftItem ? ` ${annotationData.leftItem}` : ''}
382
+ ${safeText(annotationData.leftCase)}${annotationData.leftItem ? ` ${safeText(annotationData.leftItem)}` : ''}
423
383
  </div>
424
384
  </div>
425
385
  <div class="right-annotation" style="${needsLightBackground(annotationData.caseFontColor || '#FFDE21') ? 'background: rgba(255, 255, 255, 0.9); border: 2px solid rgba(0, 0, 0, 0.2); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);' : ''}">
426
386
  <div class="case-text" style="color: ${annotationData.caseFontColor || '#FFDE21'};">
427
- ${annotationData.rightCase}${annotationData.rightItem ? ` ${annotationData.rightItem}` : ''}
387
+ ${safeText(annotationData.rightCase)}${annotationData.rightItem ? ` ${safeText(annotationData.rightItem)}` : ''}
428
388
  </div>
429
389
  </div>
430
390
  </div>
@@ -450,7 +410,7 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
450
410
  ${annotationData && annotationsSet?.has('id') ? `
451
411
  <div class="support-level-annotation">
452
412
  <div class="support-level-text" style="color: ${annotationData.supportLevel === 'ID' ? '#28a745' : annotationData.supportLevel === 'Exclusion' ? '#dc3545' : '#ffc107'}; background: ${annotationData.supportLevel === 'Inconclusive' ? 'rgba(120, 120, 120, 0.95)' : 'rgba(240, 240, 240, 0.95)'};">
453
- ${annotationData.supportLevel === 'ID' ? 'Identification' : annotationData.supportLevel}
413
+ ${safeText(annotationData.supportLevel === 'ID' ? 'Identification' : annotationData.supportLevel)}
454
414
  </div>
455
415
  </div>
456
416
  ` : '<div class="support-level-annotation"></div>'}
@@ -458,7 +418,7 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
458
418
  ${annotationData && annotationsSet?.has('class') ? `
459
419
  <div class="class-annotation">
460
420
  <div class="class-text-annotation">
461
- ${annotationData.customClass || annotationData.classType}${annotationData.classNote ? ` (${annotationData.classNote})` : ''}
421
+ ${safeText(annotationData.customClass || annotationData.classType)}${annotationData.classNote ? ` (${safeText(annotationData.classNote)})` : ''}
462
422
  </div>
463
423
  </div>
464
424
  ` : '<div class="class-annotation"></div>'}
@@ -477,20 +437,21 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
477
437
  ${annotationData && ((annotationData.includeConfirmation === true) || annotationData.additionalNotes) ? `
478
438
  <div class="confirmation-section">
479
439
  ${annotationData && (annotationData.includeConfirmation === true) ? `
440
+ <div class="confirmation-summary">
480
441
  ${annotationData.confirmationData ? `
481
442
  <div class="confirmation-data">
482
443
  <div class="confirmation-title">IDENTIFICATION CONFIRMED</div>
483
444
  <div class="confirmation-field">
484
- <div class="confirmation-name">${annotationData.confirmationData.fullName}, ${annotationData.confirmationData.badgeId}</div>
445
+ <div class="confirmation-name">${safeText(annotationData.confirmationData.fullName)}, ${safeText(annotationData.confirmationData.badgeId)}</div>
485
446
  </div>
486
447
  <div class="confirmation-field">
487
- <div class="confirmation-company">${annotationData.confirmationData.confirmedByCompany || 'N/A'}</div>
448
+ <div class="confirmation-company">${safeText(annotationData.confirmationData.confirmedByCompany || 'N/A')}</div>
488
449
  </div>
489
450
  <div class="confirmation-field">
490
- <div class="confirmation-timestamp">${annotationData.confirmationData.timestamp}</div>
451
+ <div class="confirmation-timestamp">${safeText(annotationData.confirmationData.timestamp)}</div>
491
452
  </div>
492
453
  <div class="confirmation-field">
493
- <div class="confirmation-id">ID: ${annotationData.confirmationData.confirmationId}</div>
454
+ <div class="confirmation-id">ID: ${safeText(annotationData.confirmationData.confirmationId)}</div>
494
455
  </div>
495
456
  </div>
496
457
  ` : `
@@ -501,30 +462,35 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
501
462
  <div class="confirmation-line"></div>
502
463
  </div>
503
464
  `}
504
- ` : '<div></div>'}
465
+ </div>
466
+ ` : ''}
505
467
 
506
468
  ${annotationData && annotationsSet?.has('notes') && annotationData.additionalNotes && annotationData.additionalNotes.trim() ? `
507
- <div class="additional-notes-section">${annotationData.additionalNotes.trim()}</div>
508
- ` : '<div></div>'}
469
+ <section class="additional-notes-section ${hasImage || annotationData.includeConfirmation === true ? 'notes-page' : ''}">
470
+ <h2 class="additional-notes-title">Additional Notes</h2>
471
+ <p class="additional-notes-body">${escapeHtml(annotationData.additionalNotes.trim())}</p>
472
+ </section>
473
+ ` : ''}
509
474
  </div>
510
475
  ` : ''}
511
476
 
512
477
  </div>
513
- </div>
514
-
515
- <div class="footer">
516
- <div class="footer-left">
517
- <span>Notes formatted by Striae</span>
518
- <img class="footer-brand-icon" src="${ICON_256}" alt="Striae icon" />
519
- </div>
520
- <div class="footer-center">
521
- ${userCompany ? userCompany : ''}
522
- </div>
523
- <div class="footer-right">
524
- ${notesUpdatedFormatted ? `Notes updated ${notesUpdatedFormatted}` : ''}
525
- </div>
526
- </div>
527
478
  </body>
528
479
  </html>
529
480
  `;
530
- };
481
+ };
482
+
483
+ export const getPdfOptions: ReportPdfOptionsBuilder = (data: PDFGenerationData) => buildRepeatedChromePdfOptions({
484
+ headerLeft: data.currentDate,
485
+ headerRight: data.caseNumber,
486
+ headerDetailLeft: [data.annotationData?.leftCase, data.annotationData?.leftItem].filter(Boolean).join(' / ')
487
+ ? `Left Case / Item: ${[data.annotationData?.leftCase, data.annotationData?.leftItem].filter(Boolean).join(' / ')}`
488
+ : undefined,
489
+ headerDetailRight: [data.annotationData?.rightCase, data.annotationData?.rightItem].filter(Boolean).join(' / ')
490
+ ? `Right Case / Item: ${[data.annotationData?.rightCase, data.annotationData?.rightItem].filter(Boolean).join(' / ')}`
491
+ : undefined,
492
+ footerLeft: 'Notes formatted by Striae',
493
+ footerCenter: data.userCompany,
494
+ footerRight: data.notesUpdatedFormatted ? `Notes updated ${data.notesUpdatedFormatted}` : undefined,
495
+ footerLeftImageSrc: ICON_256,
496
+ });
@@ -1,16 +1,16 @@
1
- import type { PDFGenerationData, PDFGenerationRequest, ReportModule } from './report-types';
1
+ import type { PDFGenerationData, PDFGenerationRequest, ReportModule, ReportPdfOptions } from './report-types';
2
2
 
3
3
  interface Env {
4
4
  BROWSER: Fetcher;
5
5
  PDF_WORKER_AUTH: string;
6
- ACCOUNT_ID: string;
7
- BROWSER_API_TOKEN: string;
6
+ ACCOUNT_ID?: string;
7
+ BROWSER_API_TOKEN?: string;
8
8
  }
9
9
 
10
10
  const BROWSER_PDF_TIMEOUT_MS = 90_000;
11
11
  const BROWSER_RENDERING_API_BASE = 'https://api.cloudflare.com/client/v4/accounts';
12
12
 
13
- const DEFAULT_PDF_OPTIONS = {
13
+ const DEFAULT_PDF_OPTIONS: ReportPdfOptions = {
14
14
  printBackground: true,
15
15
  format: 'letter',
16
16
  margin: {
@@ -21,6 +21,17 @@ const DEFAULT_PDF_OPTIONS = {
21
21
  },
22
22
  };
23
23
 
24
+ function resolvePdfOptions(overrides?: Partial<ReportPdfOptions>): ReportPdfOptions {
25
+ return {
26
+ ...DEFAULT_PDF_OPTIONS,
27
+ ...overrides,
28
+ margin: {
29
+ ...DEFAULT_PDF_OPTIONS.margin,
30
+ ...overrides?.margin,
31
+ },
32
+ };
33
+ }
34
+
24
35
  const reportModuleLoaders: Record<string, () => Promise<ReportModule>> = {
25
36
  // Default Striae report format module
26
37
  striae: () => import('./formats/format-striae'),
@@ -63,7 +74,11 @@ class MissingSecretError extends Error {
63
74
  }
64
75
  }
65
76
 
66
- function getRequiredSecret(value: string, name: string): string {
77
+ function getRequiredSecret(value: string | undefined, name: string): string {
78
+ if (typeof value !== 'string') {
79
+ throw new MissingSecretError(name);
80
+ }
81
+
67
82
  const normalized = value.trim();
68
83
  if (!normalized) {
69
84
  throw new MissingSecretError(name);
@@ -97,7 +112,7 @@ function resolveReportRequest(payload: unknown): PDFGenerationRequest {
97
112
  };
98
113
  }
99
114
 
100
- async function renderReport(reportFormat: string, data: PDFGenerationData): Promise<string> {
115
+ async function renderReport(reportFormat: string, data: PDFGenerationData): Promise<{ html: string; pdfOptions: ReportPdfOptions }> {
101
116
  const loader = reportModuleLoaders[reportFormat];
102
117
 
103
118
  if (!loader) {
@@ -106,17 +121,20 @@ async function renderReport(reportFormat: string, data: PDFGenerationData): Prom
106
121
  }
107
122
 
108
123
  const reportModule = await loader();
109
- return reportModule.renderReport(data);
124
+ return {
125
+ html: reportModule.renderReport(data),
126
+ pdfOptions: resolvePdfOptions(reportModule.getPdfOptions?.(data)),
127
+ };
110
128
  }
111
129
 
112
- async function renderPdfViaRestEndpoint(env: Env, html: string): Promise<Response> {
130
+ async function renderPdfViaRestEndpoint(env: Env, html: string, pdfOptions: ReportPdfOptions): Promise<Response> {
113
131
  const accountId = getRequiredSecret(env.ACCOUNT_ID, 'ACCOUNT_ID');
114
132
  const browserApiToken = getRequiredSecret(env.BROWSER_API_TOKEN, 'BROWSER_API_TOKEN');
115
133
 
116
134
  const endpoint = `${BROWSER_RENDERING_API_BASE}/${accountId}/browser-rendering/pdf`;
117
135
  const requestBody = JSON.stringify({
118
136
  html,
119
- pdfOptions: DEFAULT_PDF_OPTIONS,
137
+ pdfOptions,
120
138
  });
121
139
 
122
140
  let endpointResponse: Response;
@@ -192,7 +210,7 @@ export default {
192
210
  const { reportFormat, data } = resolveReportRequest(payload);
193
211
  const document = await renderReport(reportFormat, data);
194
212
 
195
- return await renderPdfViaRestEndpoint(env, document);
213
+ return await renderPdfViaRestEndpoint(env, document.html, document.pdfOptions);
196
214
  } catch (error) {
197
215
  if (error instanceof MissingSecretError) {
198
216
  console.error(`[pdf-worker] Configuration error: ${error.message}`);