@striae-org/striae 4.2.0 → 4.2.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 (65) hide show
  1. package/LICENSE +1 -1
  2. package/app/components/actions/case-manage.ts +50 -17
  3. package/app/components/audit/viewer/audit-entries-list.tsx +5 -2
  4. package/app/components/audit/viewer/use-audit-viewer-data.ts +6 -3
  5. package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
  6. package/app/components/canvas/confirmation/confirmation.tsx +6 -2
  7. package/app/components/colors/colors.module.css +4 -3
  8. package/app/components/navbar/navbar.tsx +34 -9
  9. package/app/components/sidebar/cases/case-sidebar.tsx +44 -70
  10. package/app/components/sidebar/cases/cases-modal.tsx +76 -35
  11. package/app/components/sidebar/cases/cases.module.css +20 -0
  12. package/app/components/sidebar/files/files-modal.tsx +37 -39
  13. package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
  14. package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +37 -74
  15. package/app/components/sidebar/notes/notes-editor-modal.tsx +5 -7
  16. package/app/components/sidebar/notes/notes.module.css +27 -11
  17. package/app/components/sidebar/sidebar-container.tsx +1 -0
  18. package/app/components/sidebar/sidebar.tsx +3 -0
  19. package/app/{tailwind.css → global.css} +1 -3
  20. package/app/hooks/useOverlayDismiss.ts +6 -4
  21. package/app/root.tsx +1 -1
  22. package/app/routes/striae/striae.tsx +6 -0
  23. package/app/services/audit/audit.service.ts +2 -2
  24. package/app/services/audit/builders/audit-event-builders-case-file.ts +1 -1
  25. package/app/types/audit.ts +1 -0
  26. package/app/utils/data/confirmation-summary/summary-core.ts +279 -0
  27. package/app/utils/data/data-operations.ts +17 -861
  28. package/app/utils/data/index.ts +11 -1
  29. package/app/utils/data/operations/batch-operations.ts +113 -0
  30. package/app/utils/data/operations/case-operations.ts +168 -0
  31. package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
  32. package/app/utils/data/operations/file-annotation-operations.ts +196 -0
  33. package/app/utils/data/operations/index.ts +7 -0
  34. package/app/utils/data/operations/signing-operations.ts +225 -0
  35. package/app/utils/data/operations/types.ts +42 -0
  36. package/app/utils/data/operations/validation-operations.ts +48 -0
  37. package/app/utils/forensics/export-verification.ts +40 -111
  38. package/functions/api/_shared/firebase-auth.ts +2 -7
  39. package/functions/api/image/[[path]].ts +20 -23
  40. package/functions/api/pdf/[[path]].ts +27 -8
  41. package/package.json +5 -10
  42. package/scripts/deploy-primershear-emails.sh +1 -1
  43. package/worker-configuration.d.ts +2 -2
  44. package/workers/audit-worker/package.json +1 -1
  45. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  46. package/workers/data-worker/package.json +1 -1
  47. package/workers/data-worker/wrangler.jsonc.example +1 -1
  48. package/workers/image-worker/package.json +1 -1
  49. package/workers/image-worker/src/image-worker.example.ts +16 -5
  50. package/workers/image-worker/wrangler.jsonc.example +1 -1
  51. package/workers/keys-worker/package.json +1 -1
  52. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  53. package/workers/pdf-worker/package.json +1 -1
  54. package/workers/pdf-worker/src/formats/format-striae.ts +1 -7
  55. package/workers/pdf-worker/src/pdf-worker.example.ts +37 -58
  56. package/workers/pdf-worker/src/report-types.ts +3 -3
  57. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  58. package/workers/user-worker/package.json +1 -1
  59. package/workers/user-worker/src/user-worker.example.ts +17 -0
  60. package/workers/user-worker/wrangler.jsonc.example +1 -1
  61. package/wrangler.toml.example +1 -1
  62. package/NOTICE +0 -13
  63. package/app/components/sidebar/notes/notes-modal.tsx +0 -52
  64. package/postcss.config.js +0 -6
  65. package/tailwind.config.ts +0 -22
@@ -1,5 +1,3 @@
1
- import firebaseConfig from '../../../app/config/firebase';
2
-
3
1
  interface FirebaseJwtHeader {
4
2
  alg?: string;
5
3
  kid?: string;
@@ -31,8 +29,6 @@ const GOOGLE_SECURETOKEN_JWKS_URL =
31
29
  'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com';
32
30
  const DEFAULT_JWKS_CACHE_SECONDS = 300;
33
31
  const CLOCK_SKEW_SECONDS = 300;
34
- const FALLBACK_PROJECT_ID =
35
- typeof firebaseConfig.projectId === 'string' ? firebaseConfig.projectId.trim() : '';
36
32
 
37
33
  const textEncoder = new TextEncoder();
38
34
  const textDecoder = new TextDecoder();
@@ -156,12 +152,11 @@ async function verifyTokenSignature(
156
152
 
157
153
  function validateTokenClaims(payload: FirebaseJwtPayload, env: Env): boolean {
158
154
  const configuredProjectId = typeof env.PROJECT_ID === 'string' ? env.PROJECT_ID.trim() : '';
159
- const allowedProjectIds = new Set([configuredProjectId, FALLBACK_PROJECT_ID].filter(Boolean));
160
- if (allowedProjectIds.size === 0) {
155
+ if (configuredProjectId.length === 0) {
161
156
  return false;
162
157
  }
163
158
 
164
- if (typeof payload.aud !== 'string' || !allowedProjectIds.has(payload.aud)) {
159
+ if (typeof payload.aud !== 'string' || payload.aud !== configuredProjectId) {
165
160
  return false;
166
161
  }
167
162
 
@@ -30,44 +30,37 @@ function normalizeWorkerBaseUrl(workerDomain: string): string {
30
30
  return `https://${trimmedDomain}`;
31
31
  }
32
32
 
33
- function extractProxyPath(url: URL): string | null {
33
+ type ProxyPathResult =
34
+ | { ok: true; path: string }
35
+ | { ok: false; reason: 'not-found' | 'bad-encoding' };
36
+
37
+ function extractProxyPath(url: URL): ProxyPathResult {
34
38
  const routePrefix = '/api/image';
35
39
  if (!url.pathname.startsWith(routePrefix)) {
36
- return null;
40
+ return { ok: false, reason: 'not-found' };
37
41
  }
38
42
 
39
43
  const remainder = url.pathname.slice(routePrefix.length);
40
44
  if (remainder.length === 0) {
41
- return '/';
45
+ return { ok: true, path: '/' };
42
46
  }
43
47
 
44
48
  const normalizedRemainder = remainder.startsWith('/') ? remainder : `/${remainder}`;
45
49
  const encodedPath = normalizedRemainder.slice(1);
50
+ if (encodedPath.length === 0) {
51
+ return { ok: true, path: normalizedRemainder };
52
+ }
46
53
 
47
54
  try {
48
55
  const decodedPath = decodeURIComponent(encodedPath);
49
- if (decodedPath.length > 0) {
50
- return decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}`;
51
- }
56
+ return { ok: true, path: decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}` };
52
57
  } catch {
53
- // Keep legacy behavior for non-encoded paths.
58
+ return { ok: false, reason: 'bad-encoding' };
54
59
  }
55
-
56
- return normalizedRemainder;
57
60
  }
58
61
 
59
62
  function resolveImageWorkerToken(env: Env): string {
60
- const imageToken = typeof env.IMAGES_API_TOKEN === 'string' ? env.IMAGES_API_TOKEN.trim() : '';
61
- if (imageToken.length > 0) {
62
- return imageToken;
63
- }
64
-
65
- const apiToken = typeof env.API_TOKEN === 'string' ? env.API_TOKEN.trim() : '';
66
- if (apiToken.length > 0) {
67
- return apiToken;
68
- }
69
-
70
- return '';
63
+ return typeof env.IMAGES_API_TOKEN === 'string' ? env.IMAGES_API_TOKEN.trim() : '';
71
64
  }
72
65
 
73
66
  export const onRequest = async ({ request, env }: ImageProxyContext): Promise<Response> => {
@@ -91,11 +84,15 @@ export const onRequest = async ({ request, env }: ImageProxyContext): Promise<Re
91
84
  }
92
85
 
93
86
  const requestUrl = new URL(request.url);
94
- const proxyPath = extractProxyPath(requestUrl);
95
- if (!proxyPath) {
96
- return textResponse('Not Found', 404);
87
+ const proxyPathResult = extractProxyPath(requestUrl);
88
+ if (!proxyPathResult.ok) {
89
+ return proxyPathResult.reason === 'bad-encoding'
90
+ ? textResponse('Bad Request: malformed image path encoding', 400)
91
+ : textResponse('Not Found', 404);
97
92
  }
98
93
 
94
+ const proxyPath = proxyPathResult.path;
95
+
99
96
  const imageWorkerToken = resolveImageWorkerToken(env);
100
97
  if (!env.IMAGES_WORKER_DOMAIN || !imageWorkerToken) {
101
98
  return textResponse('Image service not configured', 502);
@@ -9,6 +9,10 @@ const SUPPORTED_METHODS = new Set(['POST', 'OPTIONS']);
9
9
  const PRIMERSHEAR_FORMAT = 'primershear';
10
10
  const DEFAULT_FORMAT = 'striae';
11
11
 
12
+ interface PdfProxyRequestBody {
13
+ data: Record<string, unknown>;
14
+ }
15
+
12
16
  function textResponse(message: string, status: number): Response {
13
17
  return new Response(message, {
14
18
  status,
@@ -48,6 +52,21 @@ function resolveReportFormat(email: string | null, primershearEmails: string): s
48
52
  return allowed.includes(email.toLowerCase()) ? PRIMERSHEAR_FORMAT : DEFAULT_FORMAT;
49
53
  }
50
54
 
55
+ function parsePdfProxyRequestBody(payload: unknown): PdfProxyRequestBody | null {
56
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
57
+ return null;
58
+ }
59
+
60
+ const record = payload as Record<string, unknown>;
61
+ if (!record.data || typeof record.data !== 'object' || Array.isArray(record.data)) {
62
+ return null;
63
+ }
64
+
65
+ return {
66
+ data: record.data as Record<string, unknown>
67
+ };
68
+ }
69
+
51
70
  export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Response> => {
52
71
  if (!SUPPORTED_METHODS.has(request.method)) {
53
72
  return textResponse('Method not allowed', 405);
@@ -103,15 +122,15 @@ export const onRequest = async ({ request, env }: PdfProxyContext): Promise<Resp
103
122
 
104
123
  let upstreamBody: BodyInit;
105
124
  try {
106
- const payload = await request.json() as Record<string, unknown>;
107
- // Inject the server-resolved format, overriding any client-supplied value.
108
- if (payload.data && typeof payload.data === 'object') {
109
- payload.reportFormat = reportFormat;
110
- } else {
111
- // Legacy flat payload shape
112
- payload.reportFormat = reportFormat;
125
+ const payload = parsePdfProxyRequestBody(await request.json());
126
+ if (!payload) {
127
+ return textResponse('Invalid PDF request body', 400);
113
128
  }
114
- upstreamBody = JSON.stringify(payload);
129
+
130
+ upstreamBody = JSON.stringify({
131
+ data: payload.data,
132
+ reportFormat
133
+ });
115
134
  upstreamHeaders.set('Content-Type', 'application/json');
116
135
  } catch {
117
136
  return textResponse('Invalid request body', 400);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "4.2.0",
3
+ "version": "4.2.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",
@@ -44,7 +44,7 @@
44
44
  "app/entry.server.tsx",
45
45
  "app/root.tsx",
46
46
  "app/routes.ts",
47
- "app/tailwind.css",
47
+ "app/global.css",
48
48
  "react-router.config.ts",
49
49
  "load-context.ts",
50
50
  "functions/",
@@ -64,14 +64,11 @@
64
64
  ".env.example",
65
65
  "primershear.emails.example",
66
66
  "firebase.json",
67
- "postcss.config.js",
68
- "tailwind.config.ts",
69
67
  "tsconfig.json",
70
68
  "vite.config.ts",
71
69
  "worker-configuration.d.ts",
72
70
  "wrangler.toml.example",
73
- "LICENSE",
74
- "NOTICE"
71
+ "LICENSE"
75
72
  ],
76
73
  "sideEffects": false,
77
74
  "type": "module",
@@ -129,15 +126,13 @@
129
126
  "@types/react-dom": "^19.2.3",
130
127
  "@typescript-eslint/eslint-plugin": "^8.57.1",
131
128
  "@typescript-eslint/parser": "^8.57.1",
132
- "autoprefixer": "^10.4.27",
133
129
  "eslint": "^9.39.4",
134
130
  "eslint-import-resolver-typescript": "^4.4.4",
135
131
  "eslint-plugin-import": "^2.32.0",
136
132
  "eslint-plugin-jsx-a11y": "^6.10.2",
137
133
  "eslint-plugin-react": "^7.37.5",
138
134
  "eslint-plugin-react-hooks": "^7.0.1",
139
- "postcss": "^8.5.8",
140
- "tailwindcss": "^3.4.19",
135
+ "modern-normalize": "^3.0.1",
141
136
  "typescript": "^5.9.3",
142
137
  "vite": "^6.4.1",
143
138
  "vite-tsconfig-paths": "^6.1.1",
@@ -157,4 +152,4 @@
157
152
  "node": ">=20.0.0"
158
153
  },
159
154
  "packageManager": "npm@11.11.0"
160
- }
155
+ }
@@ -82,7 +82,7 @@ fi
82
82
 
83
83
  # Strip comment lines and blank lines, then join with commas
84
84
  # Use || true to avoid failure if paste gets no input (handles empty file gracefully)
85
- PRIMERSHEAR_EMAILS=$(grep -v '^\s*#' "$EMAILS_FILE" | grep -v '^\s*$' | paste -sd ',' - || true)
85
+ PRIMERSHEAR_EMAILS=$(grep -v '^[[:space:]]*#' "$EMAILS_FILE" | grep -v '^[[:space:]]*$' | paste -sd ',' - || true)
86
86
 
87
87
  if [ -z "$PRIMERSHEAR_EMAILS" ]; then
88
88
  echo -e "${YELLOW}⚠️ primershear.emails contains no active email addresses.${NC}"
@@ -5485,7 +5485,7 @@ type AIGatewayHeaders = {
5485
5485
  [key: string]: string | number | boolean | object;
5486
5486
  };
5487
5487
  type AIGatewayUniversalRequest = {
5488
- provider: AIGatewayProviders | string; // eslint-disable-line
5488
+ provider: AIGatewayProviders | string;
5489
5489
  endpoint: string;
5490
5490
  headers: Partial<AIGatewayHeaders>;
5491
5491
  query: unknown;
@@ -5501,7 +5501,7 @@ declare abstract class AiGateway {
5501
5501
  gateway?: UniversalGatewayOptions;
5502
5502
  extraHeaders?: object;
5503
5503
  }): Promise<Response>;
5504
- getUrl(provider?: AIGatewayProviders | string): Promise<string>; // eslint-disable-line
5504
+ getUrl(provider?: AIGatewayProviders | string): Promise<string>;
5505
5505
  }
5506
5506
  interface AutoRAGInternalError extends Error {
5507
5507
  }
@@ -12,7 +12,7 @@
12
12
  "@cloudflare/puppeteer": "^1.0.6",
13
13
  "@cloudflare/vitest-pool-workers": "^0.13.0",
14
14
  "vitest": "~4.1.0",
15
- "wrangler": "^4.73.0"
15
+ "wrangler": "^4.76.0"
16
16
  },
17
17
  "overrides": {
18
18
  "undici": "7.24.1",
@@ -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-20",
5
+ "compatibility_date": "2026-03-21",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -12,7 +12,7 @@
12
12
  "@cloudflare/puppeteer": "^1.0.6",
13
13
  "@cloudflare/vitest-pool-workers": "^0.13.0",
14
14
  "vitest": "~4.1.0",
15
- "wrangler": "^4.73.0"
15
+ "wrangler": "^4.76.0"
16
16
  },
17
17
  "overrides": {
18
18
  "undici": "7.24.1",
@@ -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-20",
6
+ "compatibility_date": "2026-03-21",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -12,7 +12,7 @@
12
12
  "@cloudflare/puppeteer": "^1.0.6",
13
13
  "@cloudflare/vitest-pool-workers": "^0.13.0",
14
14
  "vitest": "~4.1.0",
15
- "wrangler": "^4.73.0"
15
+ "wrangler": "^4.76.0"
16
16
  },
17
17
  "overrides": {
18
18
  "undici": "7.24.1",
@@ -145,17 +145,28 @@ async function handleImageServing(request: Request, env: Env): Promise<Response>
145
145
 
146
146
  const url = new URL(request.url);
147
147
  const encodedPath = url.pathname.slice(1);
148
- let decodedPath = encodedPath;
148
+ if (!encodedPath) {
149
+ return createResponse({ error: 'Image delivery URL is required' }, 400);
150
+ }
151
+
152
+ let decodedPath: string;
149
153
 
150
154
  try {
151
155
  decodedPath = decodeURIComponent(encodedPath);
152
156
  } catch {
153
- decodedPath = encodedPath;
157
+ return createResponse({ error: 'Image delivery URL must be URL-encoded' }, 400);
154
158
  }
155
159
 
156
- const imageDeliveryURL = new URL(
157
- decodedPath.replace('https:/imagedelivery.net', 'https://imagedelivery.net')
158
- );
160
+ let imageDeliveryURL: URL;
161
+ try {
162
+ imageDeliveryURL = new URL(decodedPath);
163
+ } catch {
164
+ return createResponse({ error: 'Image delivery URL is invalid' }, 400);
165
+ }
166
+
167
+ if (imageDeliveryURL.protocol !== 'https:' || imageDeliveryURL.hostname !== 'imagedelivery.net') {
168
+ return createResponse({ error: 'Image delivery URL must target imagedelivery.net over HTTPS' }, 400);
169
+ }
159
170
 
160
171
  return generateSignedUrl(imageDeliveryURL, env);
161
172
  }
@@ -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-20",
5
+ "compatibility_date": "2026-03-21",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -12,7 +12,7 @@
12
12
  "@cloudflare/puppeteer": "^1.0.6",
13
13
  "@cloudflare/vitest-pool-workers": "^0.13.0",
14
14
  "vitest": "~4.1.0",
15
- "wrangler": "^4.73.0"
15
+ "wrangler": "^4.76.0"
16
16
  },
17
17
  "overrides": {
18
18
  "undici": "7.24.1",
@@ -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-20",
5
+ "compatibility_date": "2026-03-21",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -13,7 +13,7 @@
13
13
  "@cloudflare/puppeteer": "^1.0.6",
14
14
  "@cloudflare/vitest-pool-workers": "^0.13.0",
15
15
  "vitest": "~4.1.0",
16
- "wrangler": "^4.73.0"
16
+ "wrangler": "^4.76.0"
17
17
  },
18
18
  "overrides": {
19
19
  "undici": "7.24.1",
@@ -63,12 +63,6 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
63
63
  return luminance < 0.5;
64
64
  };
65
65
 
66
- // Use passed currentDate or generate fallback
67
- const displayDate = currentDate || (() => {
68
- const now = new Date();
69
- return `${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getDate().toString().padStart(2, '0')}/${now.getFullYear()}`;
70
- })();
71
-
72
66
  return `
73
67
  <!DOCTYPE html>
74
68
  <html lang="en">
@@ -405,7 +399,7 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
405
399
  <div class="content-wrapper">
406
400
  <div class="header">
407
401
  <div class="header-content">
408
- <div class="date">${displayDate}</div>
402
+ <div class="date">${currentDate}</div>
409
403
  ${caseNumber ? `<div class="case-number">${caseNumber}</div>` : '<div class="case-number"></div>'}
410
404
  </div>
411
405
  </div>
@@ -3,13 +3,10 @@ import type { PDFGenerationData, PDFGenerationRequest, ReportModule } from './re
3
3
  interface Env {
4
4
  BROWSER: Fetcher;
5
5
  PDF_WORKER_AUTH: string;
6
- ACCOUNT_ID?: string;
7
- CLOUDFLARE_ACCOUNT_ID?: string;
8
- BROWSER_API_TOKEN?: string;
9
- API_TOKEN?: string;
6
+ ACCOUNT_ID: string;
7
+ BROWSER_API_TOKEN: string;
10
8
  }
11
9
 
12
- const DEFAULT_REPORT_FORMAT = 'striae';
13
10
  const BROWSER_PDF_TIMEOUT_MS = 90_000;
14
11
  const BROWSER_RENDERING_API_BASE = 'https://api.cloudflare.com/client/v4/accounts';
15
12
 
@@ -56,62 +53,47 @@ function jsonResponse(body: unknown, status: number): Response {
56
53
  });
57
54
  }
58
55
 
59
- function resolveBrowserApiToken(env: Env): string {
60
- const candidates = [env.BROWSER_API_TOKEN, env.API_TOKEN];
56
+ class MissingSecretError extends Error {
57
+ readonly secretKey: string;
61
58
 
62
- for (const candidate of candidates) {
63
- if (typeof candidate === 'string' && candidate.trim().length > 0) {
64
- return candidate.trim();
65
- }
59
+ constructor(key: string) {
60
+ super(`Worker is missing required secret: ${key}`);
61
+ this.name = 'MissingSecretError';
62
+ this.secretKey = key;
66
63
  }
67
-
68
- return '';
69
64
  }
70
65
 
71
- function resolveAccountId(env: Env): string {
72
- const candidates = [env.ACCOUNT_ID, env.CLOUDFLARE_ACCOUNT_ID];
73
-
74
- for (const candidate of candidates) {
75
- if (typeof candidate === 'string' && candidate.trim().length > 0) {
76
- return candidate.trim();
77
- }
66
+ function getRequiredSecret(value: string, name: string): string {
67
+ const normalized = value.trim();
68
+ if (!normalized) {
69
+ throw new MissingSecretError(name);
78
70
  }
79
71
 
80
- return '';
72
+ return normalized;
81
73
  }
82
74
 
83
- function normalizeReportFormat(format: unknown): string {
84
- if (typeof format !== 'string') {
85
- return DEFAULT_REPORT_FORMAT;
86
- }
87
-
88
- const normalized = format.trim().toLowerCase();
89
- return normalized || DEFAULT_REPORT_FORMAT;
90
- }
91
-
92
- function resolveReportRequest(payload: unknown): { reportFormat: string; data: PDFGenerationData } {
93
- if (!payload || typeof payload !== 'object') {
75
+ function resolveReportRequest(payload: unknown): PDFGenerationRequest {
76
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
94
77
  throw new Error('Request body must be a JSON object');
95
78
  }
96
79
 
97
80
  const record = payload as Record<string, unknown>;
98
- const reportFormat = normalizeReportFormat(record.reportFormat);
81
+ if (typeof record.reportFormat !== 'string' || record.reportFormat.trim().length === 0) {
82
+ throw new Error('Request body must include a non-empty reportFormat');
83
+ }
99
84
 
100
- if (record.data && typeof record.data === 'object') {
101
- return {
102
- reportFormat,
103
- data: record.data as PDFGenerationData,
104
- };
85
+ if (!record.data || typeof record.data !== 'object' || Array.isArray(record.data)) {
86
+ throw new Error('Request body must include a data object');
105
87
  }
106
88
 
107
- // Backward compatibility: accept legacy top-level payload shape.
108
- const legacyData: Record<string, unknown> = { ...record };
109
- delete legacyData.reportFormat;
110
- delete legacyData.data;
89
+ const data = record.data as Record<string, unknown>;
90
+ if (typeof data.currentDate !== 'string' || data.currentDate.trim().length === 0) {
91
+ throw new Error('Request body data must include a non-empty currentDate');
92
+ }
111
93
 
112
94
  return {
113
- reportFormat,
114
- data: legacyData as PDFGenerationData,
95
+ reportFormat: record.reportFormat.trim().toLowerCase(),
96
+ data: record.data as PDFGenerationData,
115
97
  };
116
98
  }
117
99
 
@@ -128,19 +110,8 @@ async function renderReport(reportFormat: string, data: PDFGenerationData): Prom
128
110
  }
129
111
 
130
112
  async function renderPdfViaRestEndpoint(env: Env, html: string): Promise<Response> {
131
- const accountId = resolveAccountId(env);
132
- const browserApiToken = resolveBrowserApiToken(env);
133
-
134
- if (!accountId || !browserApiToken) {
135
- return jsonResponse(
136
- {
137
- error: 'Missing required Browser Rendering credentials',
138
- requiredSecrets: ['ACCOUNT_ID', 'BROWSER_API_TOKEN'],
139
- note: 'Set ACCOUNT_ID and a Browser Rendering - Edit token (BROWSER_API_TOKEN) on this worker.',
140
- },
141
- 502
142
- );
143
- }
113
+ const accountId = getRequiredSecret(env.ACCOUNT_ID, 'ACCOUNT_ID');
114
+ const browserApiToken = getRequiredSecret(env.BROWSER_API_TOKEN, 'BROWSER_API_TOKEN');
144
115
 
145
116
  const endpoint = `${BROWSER_RENDERING_API_BASE}/${accountId}/browser-rendering/pdf`;
146
117
  const requestBody = JSON.stringify({
@@ -217,12 +188,20 @@ export default {
217
188
 
218
189
  if (request.method === 'POST') {
219
190
  try {
220
- const payload = await request.json() as PDFGenerationData | PDFGenerationRequest;
191
+ const payload = await request.json() as unknown;
221
192
  const { reportFormat, data } = resolveReportRequest(payload);
222
193
  const document = await renderReport(reportFormat, data);
223
194
 
224
195
  return await renderPdfViaRestEndpoint(env, document);
225
196
  } catch (error) {
197
+ if (error instanceof MissingSecretError) {
198
+ console.error(`[pdf-worker] Configuration error: ${error.message}`);
199
+ return jsonResponse(
200
+ { error: 'Worker configuration error', missing_secret: error.secretKey },
201
+ 502
202
+ );
203
+ }
204
+
226
205
  if (isTimeoutError(error)) {
227
206
  const timeoutMessage = error instanceof Error ? error.message : 'PDF generation timed out';
228
207
  return jsonResponse({ error: timeoutMessage }, 504);
@@ -52,7 +52,7 @@ export interface PDFGenerationData {
52
52
  caseNumber?: string;
53
53
  annotationData?: AnnotationData;
54
54
  activeAnnotations?: string[];
55
- currentDate?: string;
55
+ currentDate: string;
56
56
  notesUpdatedFormatted?: string;
57
57
  userCompany?: string;
58
58
  userFirstName?: string;
@@ -61,8 +61,8 @@ export interface PDFGenerationData {
61
61
  }
62
62
 
63
63
  export interface PDFGenerationRequest {
64
- reportFormat?: string;
65
- data?: PDFGenerationData;
64
+ reportFormat: string;
65
+ data: PDFGenerationData;
66
66
  }
67
67
 
68
68
  export type ReportRenderer = (data: PDFGenerationData) => string;
@@ -2,7 +2,7 @@
2
2
  "name": "PDF_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/pdf-worker.ts",
5
- "compatibility_date": "2026-03-20",
5
+ "compatibility_date": "2026-03-21",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -12,7 +12,7 @@
12
12
  "@cloudflare/puppeteer": "^1.0.6",
13
13
  "@cloudflare/vitest-pool-workers": "^0.13.0",
14
14
  "vitest": "~4.1.0",
15
- "wrangler": "^4.73.0"
15
+ "wrangler": "^4.76.0"
16
16
  },
17
17
  "overrides": {
18
18
  "undici": "7.24.1",
@@ -433,6 +433,22 @@ async function deleteSingleCase(env: Env, userUid: string, caseNumber: string):
433
433
  }
434
434
  }
435
435
 
436
+ async function deleteUserConfirmationSummary(env: Env, userUid: string): Promise<void> {
437
+ const dataApiKey = env.R2_KEY_SECRET;
438
+ const dataWorkerBaseUrl = resolveDataWorkerBaseUrl(env);
439
+ const encodedUserId = encodeURIComponent(userUid);
440
+ const confirmationSummaryPath = `${dataWorkerBaseUrl}/${encodedUserId}/meta/confirmation-status.json`;
441
+
442
+ const response = await fetch(confirmationSummaryPath, {
443
+ method: 'DELETE',
444
+ headers: { 'X-Custom-Auth-Key': dataApiKey }
445
+ });
446
+
447
+ if (!response.ok && response.status !== 404) {
448
+ throw new Error(`Failed to delete confirmation summary metadata: ${response.status}`);
449
+ }
450
+ }
451
+
436
452
  async function executeUserDeletion(
437
453
  env: Env,
438
454
  userUid: string,
@@ -490,6 +506,7 @@ async function executeUserDeletion(
490
506
  throw new Error(`Failed to fully delete all case data: ${caseCleanupErrors.join(' | ')}`);
491
507
  }
492
508
 
509
+ await deleteUserConfirmationSummary(env, userUid);
493
510
  await deleteFirebaseAuthUser(env, userUid);
494
511
 
495
512
  // Delete the user account from the database
@@ -2,7 +2,7 @@
2
2
  "name": "USER_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/user-worker.ts",
5
- "compatibility_date": "2026-03-20",
5
+ "compatibility_date": "2026-03-21",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -1,6 +1,6 @@
1
1
  #:schema node_modules/wrangler/config-schema.json
2
2
  name = "PAGES_PROJECT_NAME"
3
- compatibility_date = "2026-03-20"
3
+ compatibility_date = "2026-03-21"
4
4
  compatibility_flags = ["nodejs_compat"]
5
5
  pages_build_output_dir = "./build/client"
6
6
 
package/NOTICE DELETED
@@ -1,13 +0,0 @@
1
- NOTICE
2
-
3
- Striae – A Firearms Examiner’s Comparison Companion
4
-
5
- Striae © 2025. All rights reserved.
6
-
7
- Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License in the repository’s LICENSE file.
8
-
9
- Attributions for bundled components:
10
-
11
- Portions of this product may include open-source software licensed under their respective licenses, including but not limited to: Remix, Cloudflare Workers, Firebase, Tailwind CSS, Vite, and other npm packages. License texts for third-party components are provided in their respective source files or in node_modules as applicable.
12
-
13
- No trademark rights are granted for “Striae” or any associated names or logos.