@omniradiology/omnirad 0.1.3

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 (155) hide show
  1. package/README.md +438 -0
  2. package/app/api/ai-config/route.ts +131 -0
  3. package/app/api/ai-config/test/route.ts +49 -0
  4. package/app/api/auth/auto-login/route.ts +66 -0
  5. package/app/api/auth/check/route.ts +17 -0
  6. package/app/api/auth/login/route.ts +72 -0
  7. package/app/api/auth/logout/route.ts +25 -0
  8. package/app/api/auth/me/route.ts +75 -0
  9. package/app/api/auth/password/route.ts +49 -0
  10. package/app/api/auth/setup/route.ts +63 -0
  11. package/app/api/auth/users/route.ts +100 -0
  12. package/app/api/auth/wipe/route.ts +27 -0
  13. package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
  14. package/app/api/compliance/audit/route.ts +110 -0
  15. package/app/api/compliance/export/patient/[id]/route.ts +108 -0
  16. package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
  17. package/app/api/compliance/settings/route.ts +93 -0
  18. package/app/api/copilot/annotate/route.ts +94 -0
  19. package/app/api/copilot/chat/route.ts +238 -0
  20. package/app/api/copilot/history/route.ts +95 -0
  21. package/app/api/copilot/reports/route.ts +81 -0
  22. package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
  23. package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
  24. package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
  25. package/app/api/fhir/Patient/[id]/route.ts +26 -0
  26. package/app/api/fhir/ServiceRequest/route.ts +85 -0
  27. package/app/api/fhir/config/route.ts +102 -0
  28. package/app/api/fhir/config/test-connection/route.ts +49 -0
  29. package/app/api/fhir/metadata/route.ts +51 -0
  30. package/app/api/pacs/metadata/route.ts +32 -0
  31. package/app/api/pacs/qido/instances/route.ts +39 -0
  32. package/app/api/pacs/qido/series/route.ts +38 -0
  33. package/app/api/pacs/qido/studies/route.ts +37 -0
  34. package/app/api/pacs/test/route.ts +30 -0
  35. package/app/api/pacs/wado/render/route.ts +51 -0
  36. package/app/api/patients/[id]/reports/route.ts +18 -0
  37. package/app/api/patients/[id]/route.ts +43 -0
  38. package/app/api/patients/merge/route.ts +57 -0
  39. package/app/api/patients/route.ts +67 -0
  40. package/app/api/patients/search/route.ts +25 -0
  41. package/app/api/reports/[id]/route.ts +84 -0
  42. package/app/api/reports/[id]/status/route.ts +87 -0
  43. package/app/api/reports/clear/route.ts +16 -0
  44. package/app/api/reports/route.ts +112 -0
  45. package/app/api/segmentation-config/route.ts +238 -0
  46. package/app/api/settings/route.ts +245 -0
  47. package/app/api/settings/test-supabase/route.ts +103 -0
  48. package/app/api/upload/route.ts +48 -0
  49. package/app/copilot/page.tsx +30 -0
  50. package/app/globals.css +141 -0
  51. package/app/history/page.tsx +242 -0
  52. package/app/icon.svg +3 -0
  53. package/app/layout.tsx +47 -0
  54. package/app/login/page.tsx +175 -0
  55. package/app/pacs/page.tsx +78 -0
  56. package/app/page.tsx +125 -0
  57. package/app/patients/[id]/page.tsx +315 -0
  58. package/app/patients/page.tsx +110 -0
  59. package/app/profile/page.tsx +208 -0
  60. package/app/reports/page.tsx +432 -0
  61. package/app/settings/page.tsx +454 -0
  62. package/app/setup/page.tsx +199 -0
  63. package/components/admin/AuditLogTable.tsx +293 -0
  64. package/components/copilot/ActivityIndicator.tsx +215 -0
  65. package/components/copilot/ChatHistoryPanel.tsx +140 -0
  66. package/components/copilot/ChatMessage.tsx +251 -0
  67. package/components/copilot/ClickableReference.tsx +40 -0
  68. package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
  69. package/components/copilot/CopilotPanel.tsx +311 -0
  70. package/components/copilot/FindingsList.tsx +75 -0
  71. package/components/copilot/ViewerPanel.tsx +460 -0
  72. package/components/copilot/WorkspaceLayout.tsx +398 -0
  73. package/components/dashboard/AIConfigPanel.tsx +339 -0
  74. package/components/dashboard/AppearancePanel.tsx +491 -0
  75. package/components/dashboard/ApprovalModal.tsx +163 -0
  76. package/components/dashboard/CollaborationPanel.tsx +134 -0
  77. package/components/dashboard/CopilotConfigPanel.tsx +337 -0
  78. package/components/dashboard/DicomViewer.tsx +645 -0
  79. package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
  80. package/components/dashboard/FullReportOverlay.tsx +269 -0
  81. package/components/dashboard/ImageViewer.tsx +541 -0
  82. package/components/dashboard/PatientForm.tsx +597 -0
  83. package/components/dashboard/RejectionModal.tsx +74 -0
  84. package/components/dashboard/ReportEditor.tsx +160 -0
  85. package/components/dashboard/ReportTemplates.tsx +729 -0
  86. package/components/dashboard/ReportView.tsx +539 -0
  87. package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
  88. package/components/dashboard/StudyPlaceholder.tsx +17 -0
  89. package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
  90. package/components/dashboard/UserManagementPanel.tsx +272 -0
  91. package/components/layout/ClientLayout.tsx +39 -0
  92. package/components/layout/Header.tsx +20 -0
  93. package/components/layout/Sidebar.tsx +119 -0
  94. package/components/pacs/PacsImageViewerModal.tsx +121 -0
  95. package/components/pacs/PacsSearchFilters.tsx +117 -0
  96. package/components/pacs/PacsSeriesViewer.tsx +190 -0
  97. package/components/pacs/PacsStudyTable.tsx +113 -0
  98. package/components/patients/patient-card.tsx +117 -0
  99. package/components/patients/patient-header.tsx +122 -0
  100. package/components/patients/patient-search.tsx +137 -0
  101. package/components/patients/patient-timeline.tsx +153 -0
  102. package/components/settings/ComplianceSettingsPanel.tsx +278 -0
  103. package/components/settings/SecurityPanel.tsx +418 -0
  104. package/components/ui/badge.tsx +19 -0
  105. package/components/ui/basic.tsx +156 -0
  106. package/db/index.ts +350 -0
  107. package/db/migrations/0000_odd_quasimodo.sql +117 -0
  108. package/db/migrations/meta/0000_snapshot.json +778 -0
  109. package/db/migrations/meta/_journal.json +13 -0
  110. package/db/schema.ts +239 -0
  111. package/drizzle.config.ts +10 -0
  112. package/lib/api.ts +689 -0
  113. package/lib/auth.ts +22 -0
  114. package/lib/copilot/action-executor.ts +94 -0
  115. package/lib/copilot/action-types.ts +72 -0
  116. package/lib/copilot/coordinate-mapper.ts +84 -0
  117. package/lib/dicomImageExtractor.ts +103 -0
  118. package/lib/dicomMetadataParser.ts +111 -0
  119. package/lib/fhir/client.ts +25 -0
  120. package/lib/fhir/constants.ts +21 -0
  121. package/lib/fhir/diagnostic-report.ts +88 -0
  122. package/lib/fhir/helpers.ts +73 -0
  123. package/lib/fhir/imaging-study.ts +49 -0
  124. package/lib/fhir/patient.ts +55 -0
  125. package/lib/fhir/service-request.ts +85 -0
  126. package/lib/fhir.ts +6 -0
  127. package/lib/pacs/dicom-utils.ts +72 -0
  128. package/lib/pacs/dicomweb.ts +72 -0
  129. package/lib/pacs/server-utils.ts +37 -0
  130. package/lib/patients.ts +25 -0
  131. package/lib/pdfHelper.ts +119 -0
  132. package/lib/reportHtmlGenerator.ts +581 -0
  133. package/lib/security/audit.ts +180 -0
  134. package/lib/security/authz.ts +246 -0
  135. package/lib/security/phi-redaction.ts +156 -0
  136. package/lib/security/rate-limit.ts +106 -0
  137. package/lib/security/secrets.ts +179 -0
  138. package/lib/supabase.ts +72 -0
  139. package/lib/utils.ts +6 -0
  140. package/next.config.ts +35 -0
  141. package/package.json +76 -0
  142. package/public/file.svg +1 -0
  143. package/public/globe.svg +1 -0
  144. package/public/logo.svg +8 -0
  145. package/public/next.svg +1 -0
  146. package/public/omnirad-favicon.svg +8 -0
  147. package/public/vercel.svg +1 -0
  148. package/public/window.svg +1 -0
  149. package/tsconfig.json +34 -0
  150. package/types/copilot-viewer.ts +155 -0
  151. package/types/copilot.ts +105 -0
  152. package/types/fhir.ts +21 -0
  153. package/types/html2pdf.d.ts +20 -0
  154. package/types/index.ts +139 -0
  155. package/types/pacs.ts +41 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * OmniRad Audit Logging Module
3
+ *
4
+ * Central immutable audit trail for all PHI access and modifications.
5
+ * HIPAA §164.312(b) — Audit controls.
6
+ */
7
+ import { db, sqlite } from "@/db";
8
+ import { randomUUID } from "crypto";
9
+ import type { AuthContext } from "./authz";
10
+
11
+ // ─── Types ───────────────────────────────────────────────────────────────────
12
+
13
+ export interface AuditEvent {
14
+ actorUserId?: string;
15
+ actorRole?: string;
16
+ actorType?: "user" | "system" | "integration";
17
+ action: string;
18
+ resourceType: string;
19
+ resourceId?: string;
20
+ patientId?: string;
21
+ ipAddress?: string;
22
+ userAgent?: string;
23
+ success?: boolean;
24
+ reason?: string;
25
+ metadata?: Record<string, unknown>;
26
+ }
27
+
28
+ // ─── Table Creation (called from db/index.ts) ────────────────────────────────
29
+
30
+ export const AUDIT_LOGS_CREATE_SQL = `
31
+ CREATE TABLE IF NOT EXISTS audit_logs (
32
+ id TEXT PRIMARY KEY,
33
+ actor_user_id TEXT,
34
+ actor_role TEXT,
35
+ actor_type TEXT DEFAULT 'user',
36
+ action TEXT NOT NULL,
37
+ resource_type TEXT NOT NULL,
38
+ resource_id TEXT,
39
+ patient_id TEXT,
40
+ ip_address TEXT,
41
+ user_agent TEXT,
42
+ success INTEGER DEFAULT 1,
43
+ reason TEXT,
44
+ metadata_json TEXT,
45
+ created_at TEXT NOT NULL
46
+ );
47
+
48
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
49
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_patient_id ON audit_logs(patient_id);
50
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_user_id);
51
+ CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
52
+ `;
53
+
54
+ // ─── Core Logging Function ───────────────────────────────────────────────────
55
+
56
+ // Prepare statement once for performance (lazy init)
57
+ let _insertStmt: ReturnType<typeof sqlite.prepare> | null = null;
58
+
59
+ function getInsertStmt() {
60
+ if (!_insertStmt) {
61
+ try {
62
+ _insertStmt = sqlite.prepare(`
63
+ INSERT INTO audit_logs
64
+ (id, actor_user_id, actor_role, actor_type, action, resource_type, resource_id, patient_id, ip_address, user_agent, success, reason, metadata_json, created_at)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
66
+ `);
67
+ } catch {
68
+ // Table might not exist yet during initial setup
69
+ return null;
70
+ }
71
+ }
72
+ return _insertStmt;
73
+ }
74
+
75
+ /**
76
+ * Writes an immutable audit log entry.
77
+ * Never stores raw PHI in metadata — only IDs, counts, route names, model names, and status codes.
78
+ */
79
+ export async function auditLog(event: AuditEvent): Promise<void> {
80
+ try {
81
+ const stmt = getInsertStmt();
82
+ if (!stmt) return; // Table not ready yet
83
+
84
+ const id = randomUUID();
85
+ const now = new Date().toISOString();
86
+
87
+ // Sanitize metadata — ensure no PHI sneaks in
88
+ let metadataJson: string | null = null;
89
+ if (event.metadata) {
90
+ const safeMetadata = sanitizeAuditMetadata(event.metadata);
91
+ metadataJson = JSON.stringify(safeMetadata);
92
+ }
93
+
94
+ (stmt as any).run(
95
+ id,
96
+ event.actorUserId || null,
97
+ event.actorRole || null,
98
+ event.actorType || "user",
99
+ event.action,
100
+ event.resourceType,
101
+ event.resourceId || null,
102
+ event.patientId || null,
103
+ event.ipAddress || null,
104
+ event.userAgent || null,
105
+ event.success !== false ? 1 : 0,
106
+ event.reason || null,
107
+ metadataJson,
108
+ now
109
+ );
110
+ } catch (err) {
111
+ // Audit logging should never crash the application
112
+ console.error("[Audit] Failed to write audit log:", err instanceof Error ? err.message : "Unknown error");
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Shorthand for logging a successful event.
118
+ */
119
+ export async function auditSuccess(event: AuditEvent): Promise<void> {
120
+ return auditLog({ ...event, success: true });
121
+ }
122
+
123
+ /**
124
+ * Shorthand for logging a failed event.
125
+ */
126
+ export async function auditFailure(event: AuditEvent, error?: unknown): Promise<void> {
127
+ const reason = event.reason || (error instanceof Error ? error.message : "Unknown error");
128
+ return auditLog({ ...event, success: false, reason });
129
+ }
130
+
131
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Creates an AuditEvent from an AuthContext + action details.
135
+ */
136
+ export function auditEventFromContext(
137
+ ctx: AuthContext,
138
+ action: string,
139
+ resourceType: string,
140
+ extra?: Partial<AuditEvent>
141
+ ): AuditEvent {
142
+ return {
143
+ actorUserId: ctx.userId,
144
+ actorRole: ctx.role,
145
+ actorType: "user",
146
+ action,
147
+ resourceType,
148
+ ipAddress: ctx.ipAddress,
149
+ userAgent: ctx.userAgent,
150
+ ...extra,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Sanitize metadata to ensure no PHI is stored.
156
+ * Only allow safe primitive values and known-safe keys.
157
+ */
158
+ function sanitizeAuditMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
159
+ const SAFE_KEYS = new Set([
160
+ "method", "route", "statusCode", "status",
161
+ "count", "total", "page", "limit",
162
+ "modelName", "model_name", "providerName", "provider_name",
163
+ "purpose", "modality", "urgency", "reportStatus", "report_status",
164
+ "format", "exportFormat",
165
+ "restriction", "legalBasis", "consentStatus",
166
+ "tokenCount", "generationTimeMs",
167
+ "oldRole", "newRole", "targetUserId",
168
+ "scope", "clientType", "integrationId",
169
+ ]);
170
+
171
+ const safe: Record<string, unknown> = {};
172
+ for (const [key, value] of Object.entries(metadata)) {
173
+ if (!SAFE_KEYS.has(key)) continue;
174
+ // Only allow primitives
175
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
176
+ safe[key] = value;
177
+ }
178
+ }
179
+ return safe;
180
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * OmniRad Authorization & RBAC Module
3
+ *
4
+ * Centralized authentication and role-based access control.
5
+ * Every PHI API route MUST use one of these helpers.
6
+ */
7
+ import { db } from "@/db";
8
+ import { users, sessions } from "@/db/schema";
9
+ import { eq } from "drizzle-orm";
10
+ import { cookies } from "next/headers";
11
+ import { NextRequest } from "next/server";
12
+
13
+ // ─── Types ───────────────────────────────────────────────────────────────────
14
+
15
+ export type UserRole = "Admin" | "Radiologist" | "Technician" | "Viewer" | "User";
16
+
17
+ export type Permission =
18
+ | "patient.read" | "patient.create" | "patient.update" | "patient.delete"
19
+ | "report.read" | "report.create" | "report.update" | "report.finalize" | "report.delete"
20
+ | "image.view" | "pacs.query" | "pacs.retrieve"
21
+ | "copilot.use" | "ai.configure"
22
+ | "fhir.read" | "fhir.write"
23
+ | "settings.read" | "settings.write"
24
+ | "users.manage" | "compliance.manage"
25
+ | "data.clear" | "data.wipe";
26
+
27
+ export interface AuthContext {
28
+ userId: string;
29
+ role: UserRole;
30
+ fullName: string;
31
+ username: string;
32
+ email: string;
33
+ sessionId: string;
34
+ ipAddress: string;
35
+ userAgent: string;
36
+ }
37
+
38
+ // ─── Role-Permission Matrix ─────────────────────────────────────────────────
39
+
40
+ const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
41
+ Admin: [
42
+ "patient.read", "patient.create", "patient.update", "patient.delete",
43
+ "report.read", "report.create", "report.update", "report.finalize", "report.delete",
44
+ "image.view", "pacs.query", "pacs.retrieve",
45
+ "copilot.use", "ai.configure",
46
+ "fhir.read", "fhir.write",
47
+ "settings.read", "settings.write",
48
+ "users.manage", "compliance.manage",
49
+ "data.clear", "data.wipe",
50
+ ],
51
+ Radiologist: [
52
+ "patient.read", "patient.create", "patient.update",
53
+ "report.read", "report.create", "report.update", "report.finalize",
54
+ "image.view", "pacs.query", "pacs.retrieve",
55
+ "copilot.use",
56
+ "fhir.read",
57
+ "settings.read",
58
+ ],
59
+ Technician: [
60
+ "patient.read", "patient.create", "patient.update",
61
+ "report.read", "report.create",
62
+ "image.view", "pacs.query", "pacs.retrieve",
63
+ "settings.read",
64
+ ],
65
+ Viewer: [
66
+ "patient.read",
67
+ "report.read",
68
+ "image.view",
69
+ "settings.read",
70
+ ],
71
+ // Legacy "User" role — maps to Radiologist-level access for backward compat
72
+ User: [
73
+ "patient.read", "patient.create", "patient.update",
74
+ "report.read", "report.create", "report.update", "report.finalize",
75
+ "image.view", "pacs.query", "pacs.retrieve",
76
+ "copilot.use",
77
+ "fhir.read",
78
+ "settings.read",
79
+ ],
80
+ };
81
+
82
+ // ─── Errors ──────────────────────────────────────────────────────────────────
83
+
84
+ export class AuthError extends Error {
85
+ constructor(
86
+ message: string,
87
+ public statusCode: number = 401,
88
+ public code: string = "UNAUTHORIZED"
89
+ ) {
90
+ super(message);
91
+ this.name = "AuthError";
92
+ }
93
+ }
94
+
95
+ export class ForbiddenError extends AuthError {
96
+ constructor(message: string = "Forbidden") {
97
+ super(message, 403, "FORBIDDEN");
98
+ this.name = "ForbiddenError";
99
+ }
100
+ }
101
+
102
+ // ─── Helper: Extract IP & User-Agent ─────────────────────────────────────────
103
+
104
+ function extractClientInfo(request?: NextRequest | Request): { ipAddress: string; userAgent: string } {
105
+ if (!request) return { ipAddress: "unknown", userAgent: "unknown" };
106
+
107
+ const headers = request.headers;
108
+ const ipAddress =
109
+ headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
110
+ headers.get("x-real-ip") ||
111
+ "unknown";
112
+ const userAgent = headers.get("user-agent") || "unknown";
113
+
114
+ return { ipAddress, userAgent };
115
+ }
116
+
117
+ // ─── Core: Validate Session & Get User ───────────────────────────────────────
118
+
119
+ /**
120
+ * Validates the session cookie and returns the authenticated user context.
121
+ * Throws AuthError if not authenticated.
122
+ */
123
+ export async function requireUser(request?: NextRequest | Request): Promise<AuthContext> {
124
+ const cookieStore = await cookies();
125
+ const sessionId = cookieStore.get("omnirad_session_id")?.value;
126
+
127
+ if (!sessionId) {
128
+ throw new AuthError("Authentication required");
129
+ }
130
+
131
+ // Look up session
132
+ const sessionList = await db.select().from(sessions).where(eq(sessions.id, sessionId)).limit(1);
133
+ const session = sessionList[0];
134
+
135
+ if (!session) {
136
+ throw new AuthError("Invalid session");
137
+ }
138
+
139
+ // Check expiration
140
+ if (session.expiresAt * 1000 < Date.now()) {
141
+ // Clean up expired session
142
+ await db.delete(sessions).where(eq(sessions.id, sessionId));
143
+ throw new AuthError("Session expired");
144
+ }
145
+
146
+ // Look up user
147
+ const userList = await db.select().from(users).where(eq(users.id, session.userId)).limit(1);
148
+ const user = userList[0];
149
+
150
+ if (!user) {
151
+ throw new AuthError("User not found");
152
+ }
153
+
154
+ const { ipAddress, userAgent } = extractClientInfo(request);
155
+
156
+ return {
157
+ userId: user.id,
158
+ role: (user.role as UserRole) || "User",
159
+ fullName: user.fullName,
160
+ username: user.username,
161
+ email: user.email,
162
+ sessionId,
163
+ ipAddress,
164
+ userAgent,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Optionally gets the user context without throwing.
170
+ * Returns null if not authenticated.
171
+ */
172
+ export async function getOptionalUser(request?: NextRequest | Request): Promise<AuthContext | null> {
173
+ try {
174
+ return await requireUser(request);
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ // ─── Role Checks ─────────────────────────────────────────────────────────────
181
+
182
+ /**
183
+ * Requires the user to have one of the specified roles.
184
+ * Throws ForbiddenError if not authorized.
185
+ */
186
+ export function requireRole(ctx: AuthContext, roles: UserRole[]): void {
187
+ if (!roles.includes(ctx.role)) {
188
+ throw new ForbiddenError(
189
+ `Role '${ctx.role}' is not authorized. Required: ${roles.join(", ")}`
190
+ );
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Requires the user to have a specific permission based on their role.
196
+ * Throws ForbiddenError if not authorized.
197
+ */
198
+ export function requirePermission(ctx: AuthContext, permission: Permission): void {
199
+ const permissions = ROLE_PERMISSIONS[ctx.role];
200
+ if (!permissions || !permissions.includes(permission)) {
201
+ throw new ForbiddenError(
202
+ `Permission '${permission}' not granted for role '${ctx.role}'`
203
+ );
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Checks if a user has a specific permission (non-throwing).
209
+ */
210
+ export function hasPermission(ctx: AuthContext, permission: Permission): boolean {
211
+ const permissions = ROLE_PERMISSIONS[ctx.role];
212
+ return permissions?.includes(permission) ?? false;
213
+ }
214
+
215
+ // ─── Valid Roles List ────────────────────────────────────────────────────────
216
+
217
+ export const VALID_ROLES: UserRole[] = ["Admin", "Radiologist", "Technician", "Viewer", "User"];
218
+
219
+ /**
220
+ * Validates that a role string is a valid UserRole.
221
+ */
222
+ export function isValidRole(role: string): role is UserRole {
223
+ return VALID_ROLES.includes(role as UserRole);
224
+ }
225
+
226
+ // ─── Error Response Helper ───────────────────────────────────────────────────
227
+
228
+ import { NextResponse } from "next/server";
229
+
230
+ /**
231
+ * Wraps an API handler with auth error handling.
232
+ * Returns appropriate JSON error responses for auth failures.
233
+ */
234
+ export function handleAuthError(error: unknown): NextResponse {
235
+ if (error instanceof AuthError) {
236
+ return NextResponse.json(
237
+ { error: error.message, code: error.code },
238
+ { status: error.statusCode }
239
+ );
240
+ }
241
+ // Unknown error — don't leak details
242
+ return NextResponse.json(
243
+ { error: "Internal server error" },
244
+ { status: 500 }
245
+ );
246
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * OmniRad PHI Redaction & Safe Logging Module
3
+ *
4
+ * Prevents Protected Health Information from leaking into server logs.
5
+ * All PHI API routes must use safeLog/safeError instead of console.log/console.error.
6
+ */
7
+
8
+ // ─── PHI Field Names ────────────────────────────────────────────────────────
9
+
10
+ const PHI_FIELD_NAMES = new Set([
11
+ // Patient demographics
12
+ "patientName", "patient_name", "patientname", "name", "fullName", "full_name",
13
+ "dob", "date_of_birth", "dateOfBirth", "birthDate",
14
+ "mobile", "phone", "phoneNumber", "phone_number",
15
+ "address", "streetAddress", "street_address",
16
+ "contactInfo", "contact_info", "email",
17
+ "notes", "patientNotes",
18
+ "patientIdNumber", "patient_id_number", "mrn",
19
+ // Report content
20
+ "reportData", "report_data",
21
+ "findings", "impression", "conclusion", "technique",
22
+ "clinical_history", "clinicalHistory",
23
+ "recommendation", "recommendations",
24
+ // Images
25
+ "imageData", "image_data", "imageBase64",
26
+ "signature",
27
+ // AI
28
+ "rawLlmResponse", "raw_llm_response",
29
+ "content", // copilot chat content
30
+ "message",
31
+ "prompt", "systemPrompt", "system_prompt",
32
+ // Secrets
33
+ "apiSecretKey", "api_secret_key", "apiKey", "api_key",
34
+ "password", "passwordHash", "password_hash",
35
+ "token", "bearerToken", "bearer_token",
36
+ "pacsBearerToken", "pacs_bearer_token",
37
+ "pacsPassword", "pacs_password",
38
+ "supabaseAnonKey", "supabase_anon_key",
39
+ "langsmithApiKey", "langsmith_api_key",
40
+ "externalFhirClientSecret", "external_fhir_client_secret",
41
+ "externalFhirBearerToken", "external_fhir_bearer_token",
42
+ "apiTokenHash", "api_token_hash",
43
+ "tokenHash", "token_hash",
44
+ // FHIR payloads
45
+ "rawFhir", "raw_fhir",
46
+ // DICOM
47
+ "dicomMetadata", "dicom_metadata",
48
+ "viewerActions", "viewer_actions",
49
+ "references_data",
50
+ ]);
51
+
52
+ // ─── Base64 Detection ────────────────────────────────────────────────────────
53
+
54
+ const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4}){10,}(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
55
+
56
+ function isLikelyBase64(value: string): boolean {
57
+ if (value.length < 100) return false;
58
+ return BASE64_PATTERN.test(value.substring(0, 200));
59
+ }
60
+
61
+ // ─── Redaction ───────────────────────────────────────────────────────────────
62
+
63
+ const REDACTED = "[PHI_REDACTED]";
64
+ const SECRET_REDACTED = "[SECRET_REDACTED]";
65
+
66
+ /**
67
+ * Recursively redacts known PHI fields and base64 data from an object.
68
+ * Returns a safe copy of the value.
69
+ */
70
+ export function redactPhi(value: unknown, depth: number = 0): unknown {
71
+ // Prevent infinite recursion
72
+ if (depth > 10) return REDACTED;
73
+
74
+ if (value === null || value === undefined) return value;
75
+
76
+ if (typeof value === "string") {
77
+ // Redact base64 strings
78
+ if (isLikelyBase64(value)) return "[BASE64_REDACTED]";
79
+ // Redact long strings that look like they could contain PHI
80
+ if (value.length > 500) return `[LONG_STRING length=${value.length}]`;
81
+ return value;
82
+ }
83
+
84
+ if (typeof value === "number" || typeof value === "boolean") return value;
85
+
86
+ if (Array.isArray(value)) {
87
+ if (value.length > 20) return `[ARRAY length=${value.length}]`;
88
+ return value.map((item) => redactPhi(item, depth + 1));
89
+ }
90
+
91
+ if (typeof value === "object") {
92
+ const result: Record<string, unknown> = {};
93
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
94
+ if (PHI_FIELD_NAMES.has(key)) {
95
+ // Show type/length but not content
96
+ if (typeof val === "string") {
97
+ result[key] = val.length > 0 ? `${REDACTED} (${val.length} chars)` : "";
98
+ } else if (val === null || val === undefined) {
99
+ result[key] = val;
100
+ } else {
101
+ result[key] = REDACTED;
102
+ }
103
+ } else {
104
+ result[key] = redactPhi(val, depth + 1);
105
+ }
106
+ }
107
+ return result;
108
+ }
109
+
110
+ return REDACTED;
111
+ }
112
+
113
+ // ─── Safe Logging ────────────────────────────────────────────────────────────
114
+
115
+ /**
116
+ * Logs a message with automatic PHI redaction.
117
+ * Use instead of console.log in PHI-handling code.
118
+ */
119
+ export function safeLog(message: string, metadata?: unknown): void {
120
+ if (metadata !== undefined) {
121
+ console.log(`[OmniRad] ${message}`, redactPhi(metadata));
122
+ } else {
123
+ console.log(`[OmniRad] ${message}`);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Logs an error with automatic PHI redaction.
129
+ * Use instead of console.error in PHI-handling code.
130
+ */
131
+ export function safeError(message: string, error?: unknown): void {
132
+ if (error instanceof Error) {
133
+ // Only log the message and stack, not the full error object which may contain PHI
134
+ console.error(`[OmniRad] ${message}`, {
135
+ errorMessage: error.message,
136
+ errorName: error.name,
137
+ // Don't include stack in production
138
+ ...(process.env.NODE_ENV !== "production" && { stack: error.stack }),
139
+ });
140
+ } else if (error !== undefined) {
141
+ console.error(`[OmniRad] ${message}`, redactPhi(error));
142
+ } else {
143
+ console.error(`[OmniRad] ${message}`);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Logs a warning with automatic PHI redaction.
149
+ */
150
+ export function safeWarn(message: string, metadata?: unknown): void {
151
+ if (metadata !== undefined) {
152
+ console.warn(`[OmniRad] ${message}`, redactPhi(metadata));
153
+ } else {
154
+ console.warn(`[OmniRad] ${message}`);
155
+ }
156
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * OmniRad Rate Limiting Module
3
+ *
4
+ * Simple in-memory rate limiter for auth endpoints.
5
+ * Protects against brute-force login attempts.
6
+ */
7
+
8
+ // ─── Types ───────────────────────────────────────────────────────────────────
9
+
10
+ interface RateLimitEntry {
11
+ count: number;
12
+ resetAt: number; // Unix timestamp in ms
13
+ }
14
+
15
+ interface RateLimitResult {
16
+ allowed: boolean;
17
+ remaining: number;
18
+ resetAt: number;
19
+ }
20
+
21
+ // ─── In-Memory Store ─────────────────────────────────────────────────────────
22
+
23
+ const store = new Map<string, RateLimitEntry>();
24
+
25
+ // Cleanup interval — remove expired entries every 60 seconds
26
+ const CLEANUP_INTERVAL_MS = 60_000;
27
+ let cleanupTimer: ReturnType<typeof setInterval> | null = null;
28
+
29
+ function startCleanup(): void {
30
+ if (cleanupTimer) return;
31
+ cleanupTimer = setInterval(() => {
32
+ const now = Date.now();
33
+ for (const [key, entry] of store.entries()) {
34
+ if (entry.resetAt <= now) {
35
+ store.delete(key);
36
+ }
37
+ }
38
+ }, CLEANUP_INTERVAL_MS);
39
+
40
+ // Don't prevent process exit
41
+ if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
42
+ cleanupTimer.unref();
43
+ }
44
+ }
45
+
46
+ // ─── Rate Limit Function ─────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Checks and enforces a rate limit for a given key.
50
+ *
51
+ * @param key - Unique identifier (e.g., IP address, "login:192.168.1.1")
52
+ * @param maxAttempts - Maximum number of attempts allowed in the window
53
+ * @param windowMs - Time window in milliseconds
54
+ * @returns Object with allowed flag, remaining attempts, and reset timestamp
55
+ */
56
+ export function rateLimit(
57
+ key: string,
58
+ maxAttempts: number = 5,
59
+ windowMs: number = 60_000
60
+ ): RateLimitResult {
61
+ startCleanup();
62
+
63
+ const now = Date.now();
64
+ const entry = store.get(key);
65
+
66
+ // No entry or expired — create fresh
67
+ if (!entry || entry.resetAt <= now) {
68
+ store.set(key, {
69
+ count: 1,
70
+ resetAt: now + windowMs,
71
+ });
72
+ return {
73
+ allowed: true,
74
+ remaining: maxAttempts - 1,
75
+ resetAt: now + windowMs,
76
+ };
77
+ }
78
+
79
+ // Increment count
80
+ entry.count += 1;
81
+
82
+ if (entry.count > maxAttempts) {
83
+ return {
84
+ allowed: false,
85
+ remaining: 0,
86
+ resetAt: entry.resetAt,
87
+ };
88
+ }
89
+
90
+ return {
91
+ allowed: true,
92
+ remaining: maxAttempts - entry.count,
93
+ resetAt: entry.resetAt,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Creates a rate limit key from a request's IP address.
99
+ */
100
+ export function rateLimitKey(prefix: string, request: Request): string {
101
+ const forwarded = request.headers.get("x-forwarded-for");
102
+ const ip = forwarded?.split(",")[0]?.trim() ||
103
+ request.headers.get("x-real-ip") ||
104
+ "unknown";
105
+ return `${prefix}:${ip}`;
106
+ }