@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.
- package/README.md +438 -0
- package/app/api/ai-config/route.ts +131 -0
- package/app/api/ai-config/test/route.ts +49 -0
- package/app/api/auth/auto-login/route.ts +66 -0
- package/app/api/auth/check/route.ts +17 -0
- package/app/api/auth/login/route.ts +72 -0
- package/app/api/auth/logout/route.ts +25 -0
- package/app/api/auth/me/route.ts +75 -0
- package/app/api/auth/password/route.ts +49 -0
- package/app/api/auth/setup/route.ts +63 -0
- package/app/api/auth/users/route.ts +100 -0
- package/app/api/auth/wipe/route.ts +27 -0
- package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
- package/app/api/compliance/audit/route.ts +110 -0
- package/app/api/compliance/export/patient/[id]/route.ts +108 -0
- package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
- package/app/api/compliance/settings/route.ts +93 -0
- package/app/api/copilot/annotate/route.ts +94 -0
- package/app/api/copilot/chat/route.ts +238 -0
- package/app/api/copilot/history/route.ts +95 -0
- package/app/api/copilot/reports/route.ts +81 -0
- package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
- package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
- package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
- package/app/api/fhir/Patient/[id]/route.ts +26 -0
- package/app/api/fhir/ServiceRequest/route.ts +85 -0
- package/app/api/fhir/config/route.ts +102 -0
- package/app/api/fhir/config/test-connection/route.ts +49 -0
- package/app/api/fhir/metadata/route.ts +51 -0
- package/app/api/pacs/metadata/route.ts +32 -0
- package/app/api/pacs/qido/instances/route.ts +39 -0
- package/app/api/pacs/qido/series/route.ts +38 -0
- package/app/api/pacs/qido/studies/route.ts +37 -0
- package/app/api/pacs/test/route.ts +30 -0
- package/app/api/pacs/wado/render/route.ts +51 -0
- package/app/api/patients/[id]/reports/route.ts +18 -0
- package/app/api/patients/[id]/route.ts +43 -0
- package/app/api/patients/merge/route.ts +57 -0
- package/app/api/patients/route.ts +67 -0
- package/app/api/patients/search/route.ts +25 -0
- package/app/api/reports/[id]/route.ts +84 -0
- package/app/api/reports/[id]/status/route.ts +87 -0
- package/app/api/reports/clear/route.ts +16 -0
- package/app/api/reports/route.ts +112 -0
- package/app/api/segmentation-config/route.ts +238 -0
- package/app/api/settings/route.ts +245 -0
- package/app/api/settings/test-supabase/route.ts +103 -0
- package/app/api/upload/route.ts +48 -0
- package/app/copilot/page.tsx +30 -0
- package/app/globals.css +141 -0
- package/app/history/page.tsx +242 -0
- package/app/icon.svg +3 -0
- package/app/layout.tsx +47 -0
- package/app/login/page.tsx +175 -0
- package/app/pacs/page.tsx +78 -0
- package/app/page.tsx +125 -0
- package/app/patients/[id]/page.tsx +315 -0
- package/app/patients/page.tsx +110 -0
- package/app/profile/page.tsx +208 -0
- package/app/reports/page.tsx +432 -0
- package/app/settings/page.tsx +454 -0
- package/app/setup/page.tsx +199 -0
- package/components/admin/AuditLogTable.tsx +293 -0
- package/components/copilot/ActivityIndicator.tsx +215 -0
- package/components/copilot/ChatHistoryPanel.tsx +140 -0
- package/components/copilot/ChatMessage.tsx +251 -0
- package/components/copilot/ClickableReference.tsx +40 -0
- package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
- package/components/copilot/CopilotPanel.tsx +311 -0
- package/components/copilot/FindingsList.tsx +75 -0
- package/components/copilot/ViewerPanel.tsx +460 -0
- package/components/copilot/WorkspaceLayout.tsx +398 -0
- package/components/dashboard/AIConfigPanel.tsx +339 -0
- package/components/dashboard/AppearancePanel.tsx +491 -0
- package/components/dashboard/ApprovalModal.tsx +163 -0
- package/components/dashboard/CollaborationPanel.tsx +134 -0
- package/components/dashboard/CopilotConfigPanel.tsx +337 -0
- package/components/dashboard/DicomViewer.tsx +645 -0
- package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
- package/components/dashboard/FullReportOverlay.tsx +269 -0
- package/components/dashboard/ImageViewer.tsx +541 -0
- package/components/dashboard/PatientForm.tsx +597 -0
- package/components/dashboard/RejectionModal.tsx +74 -0
- package/components/dashboard/ReportEditor.tsx +160 -0
- package/components/dashboard/ReportTemplates.tsx +729 -0
- package/components/dashboard/ReportView.tsx +539 -0
- package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
- package/components/dashboard/StudyPlaceholder.tsx +17 -0
- package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
- package/components/dashboard/UserManagementPanel.tsx +272 -0
- package/components/layout/ClientLayout.tsx +39 -0
- package/components/layout/Header.tsx +20 -0
- package/components/layout/Sidebar.tsx +119 -0
- package/components/pacs/PacsImageViewerModal.tsx +121 -0
- package/components/pacs/PacsSearchFilters.tsx +117 -0
- package/components/pacs/PacsSeriesViewer.tsx +190 -0
- package/components/pacs/PacsStudyTable.tsx +113 -0
- package/components/patients/patient-card.tsx +117 -0
- package/components/patients/patient-header.tsx +122 -0
- package/components/patients/patient-search.tsx +137 -0
- package/components/patients/patient-timeline.tsx +153 -0
- package/components/settings/ComplianceSettingsPanel.tsx +278 -0
- package/components/settings/SecurityPanel.tsx +418 -0
- package/components/ui/badge.tsx +19 -0
- package/components/ui/basic.tsx +156 -0
- package/db/index.ts +350 -0
- package/db/migrations/0000_odd_quasimodo.sql +117 -0
- package/db/migrations/meta/0000_snapshot.json +778 -0
- package/db/migrations/meta/_journal.json +13 -0
- package/db/schema.ts +239 -0
- package/drizzle.config.ts +10 -0
- package/lib/api.ts +689 -0
- package/lib/auth.ts +22 -0
- package/lib/copilot/action-executor.ts +94 -0
- package/lib/copilot/action-types.ts +72 -0
- package/lib/copilot/coordinate-mapper.ts +84 -0
- package/lib/dicomImageExtractor.ts +103 -0
- package/lib/dicomMetadataParser.ts +111 -0
- package/lib/fhir/client.ts +25 -0
- package/lib/fhir/constants.ts +21 -0
- package/lib/fhir/diagnostic-report.ts +88 -0
- package/lib/fhir/helpers.ts +73 -0
- package/lib/fhir/imaging-study.ts +49 -0
- package/lib/fhir/patient.ts +55 -0
- package/lib/fhir/service-request.ts +85 -0
- package/lib/fhir.ts +6 -0
- package/lib/pacs/dicom-utils.ts +72 -0
- package/lib/pacs/dicomweb.ts +72 -0
- package/lib/pacs/server-utils.ts +37 -0
- package/lib/patients.ts +25 -0
- package/lib/pdfHelper.ts +119 -0
- package/lib/reportHtmlGenerator.ts +581 -0
- package/lib/security/audit.ts +180 -0
- package/lib/security/authz.ts +246 -0
- package/lib/security/phi-redaction.ts +156 -0
- package/lib/security/rate-limit.ts +106 -0
- package/lib/security/secrets.ts +179 -0
- package/lib/supabase.ts +72 -0
- package/lib/utils.ts +6 -0
- package/next.config.ts +35 -0
- package/package.json +76 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/logo.svg +8 -0
- package/public/next.svg +1 -0
- package/public/omnirad-favicon.svg +8 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
- package/types/copilot-viewer.ts +155 -0
- package/types/copilot.ts +105 -0
- package/types/fhir.ts +21 -0
- package/types/html2pdf.d.ts +20 -0
- package/types/index.ts +139 -0
- 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
|
+
}
|