eyeling 1.15.12 → 1.15.13
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 +2 -2
- package/follows-from/artifacts/ackermann.html +678 -0
- package/follows-from/artifacts/auroracare.html +1297 -0
- package/follows-from/artifacts/bike-trip.html +752 -0
- package/follows-from/artifacts/binomial-theorem.html +631 -0
- package/follows-from/artifacts/bmi.html +511 -0
- package/follows-from/artifacts/building-performance.html +750 -0
- package/follows-from/artifacts/clinical-care.html +726 -0
- package/follows-from/artifacts/collatz.html +403 -0
- package/follows-from/artifacts/complex.html +321 -0
- package/follows-from/artifacts/control-system.html +482 -0
- package/follows-from/artifacts/delfour.html +849 -0
- package/follows-from/artifacts/earthquake-epicenter.html +982 -0
- package/follows-from/artifacts/eco-route.html +662 -0
- package/follows-from/artifacts/euclid-infinitude.html +564 -0
- package/follows-from/artifacts/euler-identity.html +667 -0
- package/follows-from/artifacts/exoplanet-transit.html +1000 -0
- package/follows-from/artifacts/faltings-theorem.html +1046 -0
- package/follows-from/artifacts/fibonacci.html +299 -0
- package/follows-from/artifacts/fundamental-theorem-arithmetic.html +398 -0
- package/follows-from/artifacts/godel-numbering.html +743 -0
- package/follows-from/artifacts/gps-bike.html +759 -0
- package/follows-from/artifacts/gps-clinical-bench.html +792 -0
- package/follows-from/artifacts/graph-french.html +449 -0
- package/follows-from/artifacts/grass-molecular.html +592 -0
- package/follows-from/artifacts/group-theory.html +740 -0
- package/follows-from/artifacts/health-info.html +833 -0
- package/follows-from/artifacts/kaprekar-constant.html +576 -0
- package/follows-from/artifacts/lee.html +805 -0
- package/follows-from/artifacts/linked-lists.html +502 -0
- package/follows-from/artifacts/lldm.html +612 -0
- package/follows-from/artifacts/matrix-multiplication.html +502 -0
- package/follows-from/artifacts/matrix.html +651 -0
- package/follows-from/artifacts/newton-raphson.html +944 -0
- package/follows-from/artifacts/peano-factorial.html +456 -0
- package/follows-from/artifacts/pi.html +363 -0
- package/follows-from/artifacts/polynomial.html +646 -0
- package/follows-from/artifacts/prime.html +366 -0
- package/follows-from/artifacts/pythagorean-theorem.html +468 -0
- package/follows-from/artifacts/rest-path.html +469 -0
- package/follows-from/artifacts/roots-of-unity.html +363 -0
- package/follows-from/artifacts/turing.html +409 -0
- package/follows-from/artifacts/wind-turbines.html +726 -0
- package/follows-from/index.html +549 -0
- package/follows-from/library/index.md +22 -0
- package/follows-from/logo.svg +12 -0
- package/follows-from/manifesto.md +48 -0
- package/follows-from/method/index.md +30 -0
- package/follows-from/path/index.md +20 -0
- package/package.json +4 -3
|
@@ -0,0 +1,1297 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>AuroraCare — Purpose-based Medical Data Exchange</title>
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #f5f5f7;
|
|
11
|
+
--card-bg: #ffffff;
|
|
12
|
+
--accent: #2563eb;
|
|
13
|
+
--accent-soft: rgba(37, 99, 235, 0.1);
|
|
14
|
+
--danger: #dc2626;
|
|
15
|
+
--danger-soft: rgba(220, 38, 38, 0.08);
|
|
16
|
+
--ok: #15803d;
|
|
17
|
+
--text-main: #111827;
|
|
18
|
+
--text-muted: #6b7280;
|
|
19
|
+
--radius-xl: 18px;
|
|
20
|
+
--shadow-sm: 0 4px 12px rgba(15, 23, 42, 0.06);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
* {
|
|
24
|
+
box-sizing: border-box;
|
|
25
|
+
margin: 0;
|
|
26
|
+
padding: 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
body {
|
|
30
|
+
font-family:
|
|
31
|
+
system-ui,
|
|
32
|
+
-apple-system,
|
|
33
|
+
BlinkMacSystemFont,
|
|
34
|
+
'SF Pro Text',
|
|
35
|
+
sans-serif;
|
|
36
|
+
background: var(--bg);
|
|
37
|
+
color: var(--text-main);
|
|
38
|
+
line-height: 1.5;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.app {
|
|
42
|
+
max-width: 760px;
|
|
43
|
+
margin: 0 auto;
|
|
44
|
+
padding: 1.2rem 1rem 2.4rem;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
header.top-header {
|
|
48
|
+
margin-bottom: 1.2rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
header.top-header h1 {
|
|
52
|
+
font-size: 1.4rem;
|
|
53
|
+
font-weight: 700;
|
|
54
|
+
margin-bottom: 0.25rem;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
header.top-header p {
|
|
58
|
+
font-size: 0.92rem;
|
|
59
|
+
color: var(--text-muted);
|
|
60
|
+
margin-bottom: 0.6rem;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.status-pill {
|
|
64
|
+
display: inline-flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: 0.4rem;
|
|
67
|
+
font-size: 0.78rem;
|
|
68
|
+
text-transform: uppercase;
|
|
69
|
+
letter-spacing: 0.08em;
|
|
70
|
+
padding: 0.2rem 0.7rem;
|
|
71
|
+
border-radius: 999px;
|
|
72
|
+
background: #e5e7eb;
|
|
73
|
+
color: #374151;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.status-dot {
|
|
77
|
+
width: 0.55rem;
|
|
78
|
+
height: 0.55rem;
|
|
79
|
+
border-radius: 999px;
|
|
80
|
+
background: #9ca3af;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.status-pill.ready .status-dot {
|
|
84
|
+
background: var(--accent);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.status-pill.error .status-dot {
|
|
88
|
+
background: var(--danger);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.section {
|
|
92
|
+
margin-bottom: 1.2rem;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.section-title {
|
|
96
|
+
font-size: 1.05rem;
|
|
97
|
+
font-weight: 600;
|
|
98
|
+
margin-bottom: 0.15rem;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.section-subtitle {
|
|
102
|
+
font-size: 0.86rem;
|
|
103
|
+
color: var(--text-muted);
|
|
104
|
+
margin-bottom: 0.6rem;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.card {
|
|
108
|
+
background: var(--card-bg);
|
|
109
|
+
border-radius: var(--radius-xl);
|
|
110
|
+
box-shadow: var(--shadow-sm);
|
|
111
|
+
padding: 0.9rem 0.9rem 0.85rem;
|
|
112
|
+
margin-bottom: 0.75rem;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.card-header {
|
|
116
|
+
display: flex;
|
|
117
|
+
justify-content: space-between;
|
|
118
|
+
align-items: center;
|
|
119
|
+
gap: 0.5rem;
|
|
120
|
+
margin-bottom: 0.3rem;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.scenario-name {
|
|
124
|
+
font-weight: 600;
|
|
125
|
+
font-size: 0.96rem;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.chip {
|
|
129
|
+
font-size: 0.72rem;
|
|
130
|
+
font-weight: 600;
|
|
131
|
+
text-transform: uppercase;
|
|
132
|
+
letter-spacing: 0.06em;
|
|
133
|
+
padding: 0.15rem 0.5rem;
|
|
134
|
+
border-radius: 999px;
|
|
135
|
+
background: #eef2ff;
|
|
136
|
+
color: #3730a3;
|
|
137
|
+
white-space: nowrap;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.scenario-desc {
|
|
141
|
+
font-size: 0.88rem;
|
|
142
|
+
color: var(--text-muted);
|
|
143
|
+
margin-bottom: 0.45rem;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.pill-group {
|
|
147
|
+
display: flex;
|
|
148
|
+
flex-wrap: wrap;
|
|
149
|
+
gap: 0.25rem;
|
|
150
|
+
margin-bottom: 0.55rem;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.pill {
|
|
154
|
+
font-size: 0.76rem;
|
|
155
|
+
padding: 0.2rem 0.55rem;
|
|
156
|
+
border-radius: 999px;
|
|
157
|
+
background: #f3f4f6;
|
|
158
|
+
color: #374151;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
button.run-btn {
|
|
162
|
+
-webkit-tap-highlight-color: transparent;
|
|
163
|
+
width: 100%;
|
|
164
|
+
border: none;
|
|
165
|
+
outline: none;
|
|
166
|
+
border-radius: 999px;
|
|
167
|
+
padding: 0.6rem 0.9rem;
|
|
168
|
+
font-size: 0.95rem;
|
|
169
|
+
font-weight: 600;
|
|
170
|
+
background: var(--accent);
|
|
171
|
+
color: #fff;
|
|
172
|
+
box-shadow: 0 4px 10px rgba(37, 99, 235, 0.35);
|
|
173
|
+
display: flex;
|
|
174
|
+
align-items: center;
|
|
175
|
+
justify-content: center;
|
|
176
|
+
gap: 0.4rem;
|
|
177
|
+
cursor: pointer;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
button.run-btn span.btn-icon {
|
|
181
|
+
font-size: 1rem;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
button.run-btn:active {
|
|
185
|
+
transform: scale(0.98);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
button.run-btn[disabled] {
|
|
189
|
+
opacity: 0.6;
|
|
190
|
+
box-shadow: none;
|
|
191
|
+
cursor: default;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.result-card {
|
|
195
|
+
margin-top: 0.2rem;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.result-header {
|
|
199
|
+
display: flex;
|
|
200
|
+
align-items: center;
|
|
201
|
+
justify-content: space-between;
|
|
202
|
+
gap: 0.5rem;
|
|
203
|
+
margin-bottom: 0.6rem;
|
|
204
|
+
flex-wrap: wrap;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.status-line {
|
|
208
|
+
font-size: 0.82rem;
|
|
209
|
+
color: var(--text-muted);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.answer-pill {
|
|
213
|
+
font-size: 0.85rem;
|
|
214
|
+
font-weight: 700;
|
|
215
|
+
letter-spacing: 0.06em;
|
|
216
|
+
padding: 0.25rem 0.75rem;
|
|
217
|
+
border-radius: 999px;
|
|
218
|
+
text-transform: uppercase;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.answer-permit {
|
|
222
|
+
background: var(--accent-soft);
|
|
223
|
+
color: #1d4ed8;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.answer-deny {
|
|
227
|
+
background: var(--danger-soft);
|
|
228
|
+
color: var(--danger);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.reason {
|
|
232
|
+
font-size: 0.9rem;
|
|
233
|
+
color: var(--text-main);
|
|
234
|
+
margin-bottom: 0.5rem;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
details {
|
|
238
|
+
border-radius: 14px;
|
|
239
|
+
background: #f9fafb;
|
|
240
|
+
padding: 0.55rem 0.75rem;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
summary {
|
|
244
|
+
list-style: none;
|
|
245
|
+
cursor: pointer;
|
|
246
|
+
font-size: 0.85rem;
|
|
247
|
+
font-weight: 600;
|
|
248
|
+
color: var(--text-muted);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
summary::-webkit-details-marker {
|
|
252
|
+
display: none;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.check-list {
|
|
256
|
+
margin-top: 0.4rem;
|
|
257
|
+
max-height: 260px;
|
|
258
|
+
overflow-y: auto;
|
|
259
|
+
padding-right: 0.2rem;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.check-row {
|
|
263
|
+
font-size: 0.8rem;
|
|
264
|
+
padding: 0.12rem 0;
|
|
265
|
+
border-bottom: 1px solid #e5e7eb;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.check-row:last-child {
|
|
269
|
+
border-bottom: none;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.check-key {
|
|
273
|
+
font-weight: 600;
|
|
274
|
+
margin-right: 0.25rem;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.status-error {
|
|
278
|
+
color: var(--danger);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.status-ok {
|
|
282
|
+
color: var(--ok);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
code {
|
|
286
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
|
287
|
+
font-size: 0.85em;
|
|
288
|
+
background: #e5e7eb;
|
|
289
|
+
padding: 0.08rem 0.25rem;
|
|
290
|
+
border-radius: 0.25rem;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
@media (min-width: 640px) {
|
|
294
|
+
header.top-header h1 {
|
|
295
|
+
font-size: 1.6rem;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
</style>
|
|
299
|
+
</head>
|
|
300
|
+
<body>
|
|
301
|
+
<main class="app">
|
|
302
|
+
<header class="top-header">
|
|
303
|
+
<h1>AuroraCare — Purpose-based Medical Data Exchange</h1>
|
|
304
|
+
<p>
|
|
305
|
+
Fully client-side demo with the AuroraCare case logic translated into embedded JavaScript. Pick a scenario and
|
|
306
|
+
see
|
|
307
|
+
<strong>Answer</strong>, <strong>Reason why</strong>, and <strong>C1–C10 checks</strong>.
|
|
308
|
+
</p>
|
|
309
|
+
<div id="logic-status" class="status-pill">
|
|
310
|
+
<span class="status-dot"></span>
|
|
311
|
+
<span class="status-text">Setting up…</span>
|
|
312
|
+
</div>
|
|
313
|
+
</header>
|
|
314
|
+
|
|
315
|
+
<section class="section">
|
|
316
|
+
<h2 class="section-title">Scenarios</h2>
|
|
317
|
+
<p class="section-subtitle">
|
|
318
|
+
Vertical, phone-friendly list mirroring the original AuroraCare demo, now running directly in browser-side
|
|
319
|
+
JavaScript.
|
|
320
|
+
</p>
|
|
321
|
+
<div id="scenario-list"></div>
|
|
322
|
+
</section>
|
|
323
|
+
|
|
324
|
+
<section class="section">
|
|
325
|
+
<h2 class="section-title">Decision</h2>
|
|
326
|
+
<p class="section-subtitle">Result from the AuroraCare PDP for the selected scenario.</p>
|
|
327
|
+
<article id="result" class="card result-card" hidden></article>
|
|
328
|
+
</section>
|
|
329
|
+
</main>
|
|
330
|
+
|
|
331
|
+
<script>
|
|
332
|
+
// ----- Scenario metadata (for UI only) -----
|
|
333
|
+
const scenarios = [
|
|
334
|
+
{
|
|
335
|
+
id: 'A_primary',
|
|
336
|
+
label: 'A – Primary care visit',
|
|
337
|
+
description:
|
|
338
|
+
"Clinician in the patient's care team accessing the patient summary for primary care management.",
|
|
339
|
+
tags: ['Primary care', 'Clinician', 'Care-team linked'],
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
id: 'B_qi_in_scope',
|
|
343
|
+
label: 'B – Quality improvement (in scope)',
|
|
344
|
+
description: 'QI analyst using lab results + summary in a secure environment.',
|
|
345
|
+
tags: ['Quality & safety', 'Secure environment'],
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
id: 'C_qi_out_scope',
|
|
349
|
+
label: 'C – Quality improvement (out of scope)',
|
|
350
|
+
description: 'QI analyst with only lab results; policy expects labs + summary.',
|
|
351
|
+
tags: ['Quality & safety', 'Category out of scope'],
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
id: 'D_insurance_prohibited',
|
|
355
|
+
label: 'D – Insurance management',
|
|
356
|
+
description: 'Insurance bot attempting to use health data for insurance management (prohibited purpose).',
|
|
357
|
+
tags: ['Insurance', 'Prohibited'],
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
id: 'E_gp_checks_labs',
|
|
361
|
+
label: 'E – GP checks labs',
|
|
362
|
+
description: 'GP for the same patient checking lab results via the API gateway.',
|
|
363
|
+
tags: ['Primary care', 'Clinician', 'Care-team linked'],
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
id: 'F_research_anonymised',
|
|
367
|
+
label: 'F – Research on anonymised dataset',
|
|
368
|
+
description: 'Researcher using anonymised labs + summary in a secure environment, with opt-in.',
|
|
369
|
+
tags: ['Research', 'Anonymised', 'Opt-in'],
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
id: 'G_ai_training_optout',
|
|
373
|
+
label: 'G – AI training (opt-out)',
|
|
374
|
+
description: 'Data user wants to train AI, but the subject opted out of AI training.',
|
|
375
|
+
tags: ['AI training', 'Opt-out'],
|
|
376
|
+
},
|
|
377
|
+
];
|
|
378
|
+
|
|
379
|
+
const scenarioListEl = document.getElementById('scenario-list');
|
|
380
|
+
const resultEl = document.getElementById('result');
|
|
381
|
+
const statusEl = document.getElementById('logic-status');
|
|
382
|
+
const statusTextEl = statusEl.querySelector('.status-text');
|
|
383
|
+
const statusDotEl = statusEl.querySelector('.status-dot');
|
|
384
|
+
|
|
385
|
+
let demoReadyPromise = null;
|
|
386
|
+
let pdpInstance = null;
|
|
387
|
+
let policies = [];
|
|
388
|
+
let isBusy = false;
|
|
389
|
+
|
|
390
|
+
const ODRL = 'http://www.w3.org/ns/odrl/2/';
|
|
391
|
+
const DPV = 'https://w3id.org/dpv#';
|
|
392
|
+
const EHDS = 'https://w3id.org/dpv/legal/eu/ehds#';
|
|
393
|
+
const HLTH = 'https://w3id.org/dpv/sector/health#';
|
|
394
|
+
const EX = 'https://example.org/health#';
|
|
395
|
+
const AC = 'https://example.org/auroracare#';
|
|
396
|
+
|
|
397
|
+
const PURPOSE = {
|
|
398
|
+
PRIMARY_CARE: HLTH + 'PrimaryCareManagement',
|
|
399
|
+
REMOTE_CONSULT: HLTH + 'PatientRemoteMonitoring',
|
|
400
|
+
RESEARCH: EHDS + 'HealthcareScientificResearch',
|
|
401
|
+
QI: EHDS + 'EnsureQualitySafetyHealthcare',
|
|
402
|
+
AI_TRAINING: EHDS + 'TrainTestAndEvaluateAISystemsAlgorithms',
|
|
403
|
+
INSURANCE: HLTH + 'InsuranceManagement',
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const PRIMARY_PURPOSES = new Set([PURPOSE.PRIMARY_CARE, PURPOSE.REMOTE_CONSULT]);
|
|
407
|
+
|
|
408
|
+
class ConsentService {
|
|
409
|
+
static reset() {
|
|
410
|
+
this.store = {};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
static set(subject, purposeIri, allowed) {
|
|
414
|
+
if (!this.store[subject]) {
|
|
415
|
+
this.store[subject] = {};
|
|
416
|
+
}
|
|
417
|
+
this.store[subject][purposeIri] = allowed;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
static get(subject, purposeIri) {
|
|
421
|
+
return this.store[subject] ? this.store[subject][purposeIri] : undefined;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
class CareTeamService {
|
|
426
|
+
static reset(initialLinks) {
|
|
427
|
+
this.links = new Set(initialLinks);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
static isLinked(clinician, subject) {
|
|
431
|
+
return this.links.has(`${clinician}|${subject}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
ConsentService.store = {};
|
|
436
|
+
|
|
437
|
+
CareTeamService.links = new Set();
|
|
438
|
+
|
|
439
|
+
class ODRLEngine {
|
|
440
|
+
static opName(op) {
|
|
441
|
+
if (!op) {
|
|
442
|
+
return '';
|
|
443
|
+
}
|
|
444
|
+
if (typeof op === 'string' && op.startsWith(ODRL)) {
|
|
445
|
+
return op.split('/').filter(Boolean).pop() || '';
|
|
446
|
+
}
|
|
447
|
+
if (typeof op === 'string' && op.includes(':')) {
|
|
448
|
+
return op.split(':').pop() || '';
|
|
449
|
+
}
|
|
450
|
+
return String(op);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
static short(leftOperand) {
|
|
454
|
+
if (typeof leftOperand !== 'string') {
|
|
455
|
+
return leftOperand;
|
|
456
|
+
}
|
|
457
|
+
if (leftOperand.startsWith(DPV)) return 'dpv:' + leftOperand.slice(DPV.length);
|
|
458
|
+
if (leftOperand.startsWith(EHDS)) return 'ehds:' + leftOperand.slice(EHDS.length);
|
|
459
|
+
if (leftOperand.startsWith(HLTH)) return 'hlth:' + leftOperand.slice(HLTH.length);
|
|
460
|
+
if (leftOperand.startsWith(AC)) return 'ac:' + leftOperand.slice(AC.length);
|
|
461
|
+
if (leftOperand.startsWith(ODRL)) return 'odrl:' + leftOperand.slice(ODRL.length);
|
|
462
|
+
return leftOperand;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
static evalOp(lhs, op, rop) {
|
|
466
|
+
const opn = this.opName(op);
|
|
467
|
+
if (opn === 'eq') {
|
|
468
|
+
return lhs === rop;
|
|
469
|
+
}
|
|
470
|
+
if (opn === 'isAnyOf') {
|
|
471
|
+
if (Array.isArray(rop)) {
|
|
472
|
+
if (Array.isArray(lhs)) {
|
|
473
|
+
return lhs.some((x) => rop.includes(x));
|
|
474
|
+
}
|
|
475
|
+
return rop.includes(lhs);
|
|
476
|
+
}
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
if (opn === 'isAllOf') {
|
|
480
|
+
if (Array.isArray(lhs) && Array.isArray(rop)) {
|
|
481
|
+
return rop.every((x) => lhs.includes(x));
|
|
482
|
+
}
|
|
483
|
+
if (!Array.isArray(lhs) && Array.isArray(rop)) {
|
|
484
|
+
return rop.length === 1 && rop[0] === lhs;
|
|
485
|
+
}
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
static constraintOk(req, lop, op, rop) {
|
|
492
|
+
const left = this.short(lop);
|
|
493
|
+
if (left === 'dpv:hasPurpose') {
|
|
494
|
+
const lhs = req.purpose;
|
|
495
|
+
return [this.evalOp(lhs, op, rop), lhs];
|
|
496
|
+
}
|
|
497
|
+
if (left === 'dpv:hasRole') {
|
|
498
|
+
const lhs = req.requester_role_iri;
|
|
499
|
+
return [this.evalOp(lhs, op, rop), lhs];
|
|
500
|
+
}
|
|
501
|
+
if (left === 'dpv:hasPersonalDataCategory') {
|
|
502
|
+
const lhs = req.categories_iri;
|
|
503
|
+
return [this.evalOp(lhs, op, rop), lhs];
|
|
504
|
+
}
|
|
505
|
+
if (left === 'dpv:hasTechnicalOrganisationalMeasure') {
|
|
506
|
+
const lhs = req.toms || [];
|
|
507
|
+
return [this.evalOp(lhs, op, rop), lhs];
|
|
508
|
+
}
|
|
509
|
+
if (left === 'ac:environment') {
|
|
510
|
+
const lhs = req.environment;
|
|
511
|
+
return [this.evalOp(lhs, op, rop), lhs];
|
|
512
|
+
}
|
|
513
|
+
return [false, null];
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
static constraintsHold(req, constraints) {
|
|
517
|
+
const trace = [];
|
|
518
|
+
for (const constraint of constraints || []) {
|
|
519
|
+
const lop = constraint['odrl:leftOperand'];
|
|
520
|
+
const op = constraint['odrl:operator'];
|
|
521
|
+
const rop = Object.prototype.hasOwnProperty.call(constraint, 'odrl:rightOperandReference')
|
|
522
|
+
? constraint['odrl:rightOperandReference']
|
|
523
|
+
: constraint['odrl:rightOperand'];
|
|
524
|
+
const [ok, lhs] = this.constraintOk(req, lop, op, rop);
|
|
525
|
+
if (!ok) {
|
|
526
|
+
trace.push(
|
|
527
|
+
`constraint_failed:${lop}:${this.opName(op)}:lhs=${JSON.stringify(lhs)},rightOperand=${JSON.stringify(rop)}`,
|
|
528
|
+
);
|
|
529
|
+
return [false, trace];
|
|
530
|
+
}
|
|
531
|
+
trace.push(
|
|
532
|
+
`constraint_ok:${lop}:${this.opName(op)}:lhs=${JSON.stringify(lhs)},rightOperand=${JSON.stringify(rop)}`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
return [true, trace];
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
static match(req, policy) {
|
|
539
|
+
const trace = [];
|
|
540
|
+
const obligations = [];
|
|
541
|
+
|
|
542
|
+
for (const prohibition of policy.prohibition || []) {
|
|
543
|
+
if (![ODRL + 'use', 'odrl:use', 'use'].includes(prohibition.action)) {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const [ok, tr] = this.constraintsHold(req, prohibition.constraints || []);
|
|
547
|
+
trace.push(...tr.map((line) => `${policy.uid}:${line}`));
|
|
548
|
+
if (ok) {
|
|
549
|
+
trace.push(`${policy.uid}:deny:odrl:prohibition_matched`);
|
|
550
|
+
return [false, trace, obligations];
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
for (const permission of policy.permission || []) {
|
|
555
|
+
if (![ODRL + 'use', 'odrl:use', 'use'].includes(permission.action)) {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
const [ok, tr] = this.constraintsHold(req, permission.constraints || []);
|
|
559
|
+
trace.push(...tr.map((line) => `${policy.uid}:${line}`));
|
|
560
|
+
if (ok) {
|
|
561
|
+
for (const duty of permission.duty || []) {
|
|
562
|
+
const action = duty['odrl:action'];
|
|
563
|
+
if (action) {
|
|
564
|
+
obligations.push(`duty:${action}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
trace.push(`${policy.uid}:permit:odrl:permission_matched`);
|
|
568
|
+
return [true, trace, obligations];
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
trace.push(`${policy.uid}:deny:odrl:no_permission_matched`);
|
|
573
|
+
return [false, trace, obligations];
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
class PDP {
|
|
578
|
+
constructor(policyList) {
|
|
579
|
+
this.policies = policyList;
|
|
580
|
+
this.PROHIBITED_PURPOSES = new Set([PURPOSE.INSURANCE]);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
evalPolicies(req) {
|
|
584
|
+
const agg = [];
|
|
585
|
+
const obligations = [];
|
|
586
|
+
for (const policy of this.policies) {
|
|
587
|
+
const [ok, tr, obl] = ODRLEngine.match(req, policy);
|
|
588
|
+
agg.push(...tr);
|
|
589
|
+
if (ok) {
|
|
590
|
+
obligations.push(...obl);
|
|
591
|
+
return [true, agg, obligations];
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return [false, agg, obligations];
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
summarize(req) {
|
|
598
|
+
let matched = null;
|
|
599
|
+
let duties = [];
|
|
600
|
+
let okAny = false;
|
|
601
|
+
let prohibition = false;
|
|
602
|
+
let matchedUid = null;
|
|
603
|
+
for (const policy of this.policies) {
|
|
604
|
+
const [ok, tr, obl] = ODRLEngine.match(req, policy);
|
|
605
|
+
if (tr.some((line) => line.endsWith(':deny:odrl:prohibition_matched'))) {
|
|
606
|
+
prohibition = true;
|
|
607
|
+
}
|
|
608
|
+
if (ok && !okAny) {
|
|
609
|
+
okAny = true;
|
|
610
|
+
matched = policy;
|
|
611
|
+
duties = obl;
|
|
612
|
+
matchedUid = policy.uid;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return { okAny, matched, duties, prohibition, matchedUid };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
checks(req, answer, trace, obligations) {
|
|
619
|
+
const { okAny, matched, duties, prohibition, matchedUid } = this.summarize(req);
|
|
620
|
+
const hasCareteam = CareTeamService.isLinked(req.requester_id, req.subject_id);
|
|
621
|
+
const hasOptin = ConsentService.get(req.subject_id, req.purpose) === true;
|
|
622
|
+
const out = {};
|
|
623
|
+
|
|
624
|
+
if (this.PROHIBITED_PURPOSES.has(req.purpose)) {
|
|
625
|
+
out['C1_prohibited_denied'] =
|
|
626
|
+
answer === 'DENY' ? 'OK - denied prohibited purpose' : 'FAIL - prohibited purpose was not denied';
|
|
627
|
+
} else {
|
|
628
|
+
out['C1_prohibited_denied'] = 'SKIPPED - not a prohibited purpose';
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (PRIMARY_PURPOSES.has(req.purpose)) {
|
|
632
|
+
out['C2_primary_role'] =
|
|
633
|
+
req.requester_role === 'clinician'
|
|
634
|
+
? 'OK - clinician'
|
|
635
|
+
: answer === 'DENY'
|
|
636
|
+
? 'OK - non-clinician denied'
|
|
637
|
+
: 'FAIL - non-clinician permitted';
|
|
638
|
+
|
|
639
|
+
out['C3_primary_careteam'] =
|
|
640
|
+
hasCareteam && answer === 'PERMIT'
|
|
641
|
+
? 'OK - care-team linked'
|
|
642
|
+
: !hasCareteam && answer === 'DENY'
|
|
643
|
+
? 'OK - denied due to missing care-team'
|
|
644
|
+
: 'FAIL - care-team rule inconsistent with answer';
|
|
645
|
+
} else {
|
|
646
|
+
out['C2_primary_role'] = 'SKIPPED';
|
|
647
|
+
out['C3_primary_careteam'] = 'SKIPPED';
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (!PRIMARY_PURPOSES.has(req.purpose) && !this.PROHIBITED_PURPOSES.has(req.purpose)) {
|
|
651
|
+
const expectPermit = (hasOptin || req.purpose === PURPOSE.QI) && okAny;
|
|
652
|
+
out['C4_secondary_optin_and_policy'] =
|
|
653
|
+
answer === 'PERMIT' && expectPermit
|
|
654
|
+
? 'OK - opt-in present and policy matched'
|
|
655
|
+
: answer === 'DENY' && !expectPermit
|
|
656
|
+
? 'OK - denied because opt-in missing or no policy match'
|
|
657
|
+
: answer === 'PERMIT'
|
|
658
|
+
? 'FAIL - permitted without opt-in and matching policy'
|
|
659
|
+
: 'FAIL - denied despite opt-in and policy match';
|
|
660
|
+
} else {
|
|
661
|
+
out['C4_secondary_optin_and_policy'] = 'SKIPPED';
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (matched && answer === 'PERMIT') {
|
|
665
|
+
let catOk = true;
|
|
666
|
+
let msg = '';
|
|
667
|
+
const permissions = matched.permission || [];
|
|
668
|
+
if (permissions.length > 0) {
|
|
669
|
+
const constraints = permissions[0].constraints || [];
|
|
670
|
+
const categoryConstraints = constraints.filter((c) =>
|
|
671
|
+
String(c['odrl:leftOperand'] || '').endsWith('hasPersonalDataCategory'),
|
|
672
|
+
);
|
|
673
|
+
if (categoryConstraints.length > 0) {
|
|
674
|
+
const op = ODRLEngine.opName(categoryConstraints[0]['odrl:operator']);
|
|
675
|
+
const allowed = categoryConstraints[0]['odrl:rightOperandReference'] || [];
|
|
676
|
+
const reqCats = [...req.categories_iri];
|
|
677
|
+
if (op === 'isAllOf') {
|
|
678
|
+
catOk = allowed.every((x) => reqCats.includes(x));
|
|
679
|
+
} else if (op === 'isAnyOf') {
|
|
680
|
+
catOk = reqCats.some((x) => allowed.includes(x));
|
|
681
|
+
}
|
|
682
|
+
msg = `operator=${op}, allowed=${JSON.stringify(allowed)}, requested=${JSON.stringify(reqCats)}`;
|
|
683
|
+
out['C5_category_scope'] = catOk ? `OK - ${msg}` : `FAIL - out of scope: ${msg}`;
|
|
684
|
+
} else {
|
|
685
|
+
out['C5_category_scope'] = 'SKIPPED';
|
|
686
|
+
}
|
|
687
|
+
} else {
|
|
688
|
+
out['C5_category_scope'] = 'SKIPPED';
|
|
689
|
+
}
|
|
690
|
+
} else {
|
|
691
|
+
out['C5_category_scope'] = 'SKIPPED';
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
out['C6_prohibition_blocks'] =
|
|
695
|
+
prohibition && answer === 'DENY'
|
|
696
|
+
? 'OK - denied due to prohibition'
|
|
697
|
+
: prohibition && answer === 'PERMIT'
|
|
698
|
+
? 'FAIL - permitted despite prohibition'
|
|
699
|
+
: 'SKIPPED - no prohibition matched';
|
|
700
|
+
|
|
701
|
+
out['C7_trace_consistency'] =
|
|
702
|
+
answer === 'PERMIT' &&
|
|
703
|
+
(trace.some((line) => line.endsWith(':permit:odrl:permission_matched')) ||
|
|
704
|
+
trace.includes('permit:primary_care_allowed'))
|
|
705
|
+
? 'OK - trace shows matching permission'
|
|
706
|
+
: answer !== 'PERMIT'
|
|
707
|
+
? 'SKIPPED'
|
|
708
|
+
: 'FAIL - missing permission marker in trace';
|
|
709
|
+
|
|
710
|
+
out['C8_duties_present'] =
|
|
711
|
+
duties.length > 0
|
|
712
|
+
? `INFO - duties attached: ${duties.join(', ')}`
|
|
713
|
+
: 'SKIPPED - no matched policy or no duties';
|
|
714
|
+
|
|
715
|
+
if (matched && answer === 'PERMIT') {
|
|
716
|
+
const permissions = matched.permission || [];
|
|
717
|
+
let envOk = true;
|
|
718
|
+
let msg = '';
|
|
719
|
+
let had = false;
|
|
720
|
+
if (permissions.length > 0) {
|
|
721
|
+
const constraints = permissions[0].constraints || [];
|
|
722
|
+
const envConstraints = constraints.filter((c) => c['odrl:leftOperand'] === AC + 'environment');
|
|
723
|
+
if (envConstraints.length > 0) {
|
|
724
|
+
had = true;
|
|
725
|
+
const op = ODRLEngine.opName(envConstraints[0]['odrl:operator']);
|
|
726
|
+
const allowed = envConstraints[0]['odrl:rightOperand'];
|
|
727
|
+
if (op === 'eq') {
|
|
728
|
+
envOk = req.environment === allowed;
|
|
729
|
+
} else if (op === 'isAnyOf') {
|
|
730
|
+
envOk = Array.isArray(allowed)
|
|
731
|
+
? allowed.includes(req.environment)
|
|
732
|
+
: [allowed].includes(req.environment);
|
|
733
|
+
}
|
|
734
|
+
msg = `operator=${op}, allowed=${JSON.stringify(allowed)}, requested=${JSON.stringify(req.environment)}`;
|
|
735
|
+
out['C9_environment_scope'] =
|
|
736
|
+
had && envOk
|
|
737
|
+
? `OK - ${msg}`
|
|
738
|
+
: had
|
|
739
|
+
? `FAIL - out of scope: ${msg}`
|
|
740
|
+
: 'SKIPPED - policy has no environment constraint';
|
|
741
|
+
} else {
|
|
742
|
+
out['C9_environment_scope'] = 'SKIPPED - policy has no environment constraint';
|
|
743
|
+
}
|
|
744
|
+
} else {
|
|
745
|
+
out['C9_environment_scope'] = 'SKIPPED - policy has no environment constraint';
|
|
746
|
+
}
|
|
747
|
+
} else {
|
|
748
|
+
out['C9_environment_scope'] = 'SKIPPED';
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
out['C10_policy_uid'] = matched ? `INFO - matched policy: ${matchedUid}` : 'SKIPPED - no matched policy';
|
|
752
|
+
|
|
753
|
+
return out;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
finalize(req, answer, reasonWhy, trace, obligations) {
|
|
757
|
+
return {
|
|
758
|
+
Answer: answer,
|
|
759
|
+
'Reason why': reasonWhy,
|
|
760
|
+
Check: this.checks(req, answer, trace, obligations),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
decide(req) {
|
|
765
|
+
const trace = [];
|
|
766
|
+
const obligations = [];
|
|
767
|
+
|
|
768
|
+
if (this.PROHIBITED_PURPOSES.has(req.purpose)) {
|
|
769
|
+
trace.push('deny:prohibited_purpose');
|
|
770
|
+
return this.finalize(
|
|
771
|
+
req,
|
|
772
|
+
'DENY',
|
|
773
|
+
'Denied: the requested purpose (insurance management) is prohibited by policy.',
|
|
774
|
+
trace,
|
|
775
|
+
obligations,
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (PRIMARY_PURPOSES.has(req.purpose)) {
|
|
780
|
+
if (req.requester_role !== 'clinician') {
|
|
781
|
+
trace.push('deny:primary_only_for_clinicians');
|
|
782
|
+
return this.finalize(
|
|
783
|
+
req,
|
|
784
|
+
'DENY',
|
|
785
|
+
'Denied: primary-care access is limited to clinicians.',
|
|
786
|
+
trace,
|
|
787
|
+
obligations,
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
trace.push('ok:role=CLINICIAN');
|
|
792
|
+
|
|
793
|
+
if (!CareTeamService.isLinked(req.requester_id, req.subject_id)) {
|
|
794
|
+
trace.push('deny:not_in_care_team');
|
|
795
|
+
return this.finalize(
|
|
796
|
+
req,
|
|
797
|
+
'DENY',
|
|
798
|
+
"Denied: requester is not linked to the patient's care team.",
|
|
799
|
+
trace,
|
|
800
|
+
obligations,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
trace.push('ok:careteam_link');
|
|
805
|
+
const [ok, tr, obl] = this.evalPolicies(req);
|
|
806
|
+
trace.push(...tr);
|
|
807
|
+
obligations.push(...obl);
|
|
808
|
+
if (ok) {
|
|
809
|
+
trace.push('permit:primary_care_allowed');
|
|
810
|
+
return this.finalize(
|
|
811
|
+
req,
|
|
812
|
+
'PERMIT',
|
|
813
|
+
"Permitted: clinician in the patient's care team, and the primary-care policy matched.",
|
|
814
|
+
trace,
|
|
815
|
+
obligations,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
return this.finalize(
|
|
819
|
+
req,
|
|
820
|
+
'DENY',
|
|
821
|
+
'Denied: no primary-care policy matched for the requested scope.',
|
|
822
|
+
trace,
|
|
823
|
+
obligations,
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (req.purpose === PURPOSE.AI_TRAINING) {
|
|
828
|
+
const aiPref = ConsentService.get(req.subject_id, PURPOSE.AI_TRAINING);
|
|
829
|
+
if (aiPref === false) {
|
|
830
|
+
trace.push('deny:subject_opted_out_ai_training');
|
|
831
|
+
return this.finalize(
|
|
832
|
+
req,
|
|
833
|
+
'DENY',
|
|
834
|
+
'Denied: you opted out of your data being used to train AI systems.',
|
|
835
|
+
trace,
|
|
836
|
+
obligations,
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const pref = ConsentService.get(req.subject_id, req.purpose);
|
|
842
|
+
if (req.purpose === PURPOSE.RESEARCH && pref !== true) {
|
|
843
|
+
trace.push('deny:no_subject_opt_in');
|
|
844
|
+
return this.finalize(req, 'DENY', 'Denied: no explicit opt-in for this secondary use.', trace, obligations);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (pref === false) {
|
|
848
|
+
trace.push('deny:subject_opted_out');
|
|
849
|
+
return this.finalize(
|
|
850
|
+
req,
|
|
851
|
+
'DENY',
|
|
852
|
+
'Denied: the data subject has opted out of this secondary use.',
|
|
853
|
+
trace,
|
|
854
|
+
obligations,
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const [ok, tr, obl] = this.evalPolicies(req);
|
|
859
|
+
trace.push(...tr);
|
|
860
|
+
obligations.push(...obl);
|
|
861
|
+
|
|
862
|
+
if (!ok) {
|
|
863
|
+
return this.finalize(
|
|
864
|
+
req,
|
|
865
|
+
'DENY',
|
|
866
|
+
'Denied: no policy matched (purpose, environment, TOMs, or categories out of scope).',
|
|
867
|
+
trace,
|
|
868
|
+
obligations,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return this.finalize(
|
|
873
|
+
req,
|
|
874
|
+
'PERMIT',
|
|
875
|
+
req.purpose === PURPOSE.RESEARCH
|
|
876
|
+
? 'Permitted: subject opted in and an ODRL/DPV policy matched (anonymised dataset in secure environment).'
|
|
877
|
+
: 'Permitted: ODRL/DPV policy matched for secondary use.',
|
|
878
|
+
trace,
|
|
879
|
+
obligations,
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function buildPolicies() {
|
|
885
|
+
return [
|
|
886
|
+
{
|
|
887
|
+
uid: 'urn:policy:primary-care-001',
|
|
888
|
+
permission: [
|
|
889
|
+
{
|
|
890
|
+
action: ODRL + 'use',
|
|
891
|
+
target: 'urn:asset:ehr',
|
|
892
|
+
constraints: [
|
|
893
|
+
{
|
|
894
|
+
'odrl:leftOperand': DPV + 'hasPurpose',
|
|
895
|
+
'odrl:operator': ODRL + 'isAnyOf',
|
|
896
|
+
'odrl:rightOperandReference': [HLTH + 'PrimaryCareManagement', HLTH + 'PatientRemoteMonitoring'],
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
'odrl:leftOperand': DPV + 'hasRole',
|
|
900
|
+
'odrl:operator': ODRL + 'eq',
|
|
901
|
+
'odrl:rightOperandReference': EX + 'Clinician',
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
'odrl:leftOperand': DPV + 'hasPersonalDataCategory',
|
|
905
|
+
'odrl:operator': ODRL + 'isAnyOf',
|
|
906
|
+
'odrl:rightOperandReference': [EX + 'PATIENT_SUMMARY', EX + 'LAB_RESULTS'],
|
|
907
|
+
},
|
|
908
|
+
],
|
|
909
|
+
duty: [],
|
|
910
|
+
},
|
|
911
|
+
],
|
|
912
|
+
prohibition: [],
|
|
913
|
+
},
|
|
914
|
+
{
|
|
915
|
+
uid: 'urn:policy:qi-2025-aurora',
|
|
916
|
+
permission: [
|
|
917
|
+
{
|
|
918
|
+
action: ODRL + 'use',
|
|
919
|
+
target: 'urn:asset:ehr',
|
|
920
|
+
constraints: [
|
|
921
|
+
{
|
|
922
|
+
'odrl:leftOperand': DPV + 'hasPurpose',
|
|
923
|
+
'odrl:operator': ODRL + 'eq',
|
|
924
|
+
'odrl:rightOperandReference': EHDS + 'EnsureQualitySafetyHealthcare',
|
|
925
|
+
},
|
|
926
|
+
{
|
|
927
|
+
'odrl:leftOperand': AC + 'environment',
|
|
928
|
+
'odrl:operator': ODRL + 'eq',
|
|
929
|
+
'odrl:rightOperand': 'secure_env',
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
'odrl:leftOperand': DPV + 'hasPersonalDataCategory',
|
|
933
|
+
'odrl:operator': ODRL + 'isAllOf',
|
|
934
|
+
'odrl:rightOperandReference': [EX + 'LAB_RESULTS', EX + 'PATIENT_SUMMARY'],
|
|
935
|
+
},
|
|
936
|
+
],
|
|
937
|
+
duty: [{ 'odrl:action': EHDS + 'requireConsent' }, { 'odrl:action': EHDS + 'noExfiltration' }],
|
|
938
|
+
},
|
|
939
|
+
],
|
|
940
|
+
prohibition: [],
|
|
941
|
+
},
|
|
942
|
+
{
|
|
943
|
+
uid: 'urn:policy:research-aurora-diabetes',
|
|
944
|
+
permission: [
|
|
945
|
+
{
|
|
946
|
+
action: ODRL + 'use',
|
|
947
|
+
target: 'urn:asset:ehr',
|
|
948
|
+
constraints: [
|
|
949
|
+
{
|
|
950
|
+
'odrl:leftOperand': DPV + 'hasPurpose',
|
|
951
|
+
'odrl:operator': ODRL + 'eq',
|
|
952
|
+
'odrl:rightOperandReference': EHDS + 'HealthcareScientificResearch',
|
|
953
|
+
},
|
|
954
|
+
{
|
|
955
|
+
'odrl:leftOperand': AC + 'environment',
|
|
956
|
+
'odrl:operator': ODRL + 'eq',
|
|
957
|
+
'odrl:rightOperand': 'secure_env',
|
|
958
|
+
},
|
|
959
|
+
{
|
|
960
|
+
'odrl:leftOperand': DPV + 'hasTechnicalOrganisationalMeasure',
|
|
961
|
+
'odrl:operator': ODRL + 'isAnyOf',
|
|
962
|
+
'odrl:rightOperandReference': [DPV + 'Anonymisation'],
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
'odrl:leftOperand': DPV + 'hasPersonalDataCategory',
|
|
966
|
+
'odrl:operator': ODRL + 'isAnyOf',
|
|
967
|
+
'odrl:rightOperandReference': [EX + 'LAB_RESULTS', EX + 'PATIENT_SUMMARY', EX + 'IMAGING_REPORT'],
|
|
968
|
+
},
|
|
969
|
+
],
|
|
970
|
+
duty: [
|
|
971
|
+
{ 'odrl:action': EHDS + 'annualOutcomeReport' },
|
|
972
|
+
{ 'odrl:action': EHDS + 'noReidentification' },
|
|
973
|
+
{ 'odrl:action': EHDS + 'noExfiltration' },
|
|
974
|
+
],
|
|
975
|
+
},
|
|
976
|
+
],
|
|
977
|
+
prohibition: [],
|
|
978
|
+
},
|
|
979
|
+
{
|
|
980
|
+
uid: 'urn:policy:deny-insurance',
|
|
981
|
+
permission: [],
|
|
982
|
+
prohibition: [
|
|
983
|
+
{
|
|
984
|
+
action: ODRL + 'use',
|
|
985
|
+
target: null,
|
|
986
|
+
constraints: [
|
|
987
|
+
{
|
|
988
|
+
'odrl:leftOperand': DPV + 'hasPurpose',
|
|
989
|
+
'odrl:operator': ODRL + 'eq',
|
|
990
|
+
'odrl:rightOperandReference': HLTH + 'InsuranceManagement',
|
|
991
|
+
},
|
|
992
|
+
],
|
|
993
|
+
duty: [],
|
|
994
|
+
},
|
|
995
|
+
],
|
|
996
|
+
},
|
|
997
|
+
];
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const RAW_SCENARIOS = {
|
|
1001
|
+
A_primary: ['clinician_alba', 'clinician', 'ruben', 'PRIMARY_CARE', ['PATIENT_SUMMARY'], 'api_gateway', false],
|
|
1002
|
+
B_qi_in_scope: [
|
|
1003
|
+
'qi_analyst',
|
|
1004
|
+
'data_user',
|
|
1005
|
+
'ruben',
|
|
1006
|
+
'QI',
|
|
1007
|
+
['LAB_RESULTS', 'PATIENT_SUMMARY'],
|
|
1008
|
+
'secure_env',
|
|
1009
|
+
false,
|
|
1010
|
+
],
|
|
1011
|
+
C_qi_out_scope: ['qi_analyst', 'data_user', 'ruben', 'QI', ['LAB_RESULTS'], 'secure_env', false],
|
|
1012
|
+
D_insurance_prohibited: [
|
|
1013
|
+
'insurer_bot',
|
|
1014
|
+
'data_user',
|
|
1015
|
+
'ruben',
|
|
1016
|
+
'INSURANCE',
|
|
1017
|
+
['PATIENT_SUMMARY'],
|
|
1018
|
+
'secure_env',
|
|
1019
|
+
false,
|
|
1020
|
+
],
|
|
1021
|
+
E_gp_checks_labs: ['gp_ruben', 'clinician', 'ruben', 'PRIMARY_CARE', ['LAB_RESULTS'], 'api_gateway', false],
|
|
1022
|
+
F_research_anonymised: [
|
|
1023
|
+
'researcher_aurora',
|
|
1024
|
+
'data_user',
|
|
1025
|
+
'ruben',
|
|
1026
|
+
'RESEARCH',
|
|
1027
|
+
['PATIENT_SUMMARY', 'LAB_RESULTS'],
|
|
1028
|
+
'secure_env',
|
|
1029
|
+
true,
|
|
1030
|
+
],
|
|
1031
|
+
G_ai_training_optout: [
|
|
1032
|
+
'ml_ops',
|
|
1033
|
+
'data_user',
|
|
1034
|
+
'ruben',
|
|
1035
|
+
'AI_TRAINING',
|
|
1036
|
+
['PATIENT_SUMMARY', 'LAB_RESULTS'],
|
|
1037
|
+
'secure_env',
|
|
1038
|
+
false,
|
|
1039
|
+
],
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
function buildRequest(
|
|
1043
|
+
requesterId,
|
|
1044
|
+
requesterRole,
|
|
1045
|
+
subjectId,
|
|
1046
|
+
purposeIri,
|
|
1047
|
+
categories,
|
|
1048
|
+
environment,
|
|
1049
|
+
anonymised = false,
|
|
1050
|
+
) {
|
|
1051
|
+
return {
|
|
1052
|
+
request_id: `req_${requesterId}_${purposeIri.split('#').pop()}`,
|
|
1053
|
+
requester_id: requesterId,
|
|
1054
|
+
requester_role: requesterRole,
|
|
1055
|
+
requester_role_iri: EX + (requesterRole === 'clinician' ? 'Clinician' : 'DataUser'),
|
|
1056
|
+
subject_id: subjectId,
|
|
1057
|
+
purpose: purposeIri,
|
|
1058
|
+
categories: [...categories],
|
|
1059
|
+
categories_iri: categories.map((c) => EX + c),
|
|
1060
|
+
environment,
|
|
1061
|
+
toms: anonymised ? [DPV + 'Anonymisation'] : [],
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function resetServices() {
|
|
1066
|
+
ConsentService.reset();
|
|
1067
|
+
CareTeamService.reset(['clinician_alba|ruben', 'gp_ruben|ruben']);
|
|
1068
|
+
ConsentService.set('ruben', PURPOSE.RESEARCH, true);
|
|
1069
|
+
ConsentService.set('ruben', PURPOSE.AI_TRAINING, false);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function makeRequestFromKey(name) {
|
|
1073
|
+
const [requesterId, requesterRole, subjectId, purposeKey, categories, environment, anonymised] =
|
|
1074
|
+
RAW_SCENARIOS[name];
|
|
1075
|
+
return buildRequest(
|
|
1076
|
+
requesterId,
|
|
1077
|
+
requesterRole,
|
|
1078
|
+
subjectId,
|
|
1079
|
+
PURPOSE[purposeKey],
|
|
1080
|
+
categories,
|
|
1081
|
+
environment,
|
|
1082
|
+
anonymised,
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function runNamedScenario(name) {
|
|
1087
|
+
resetServices();
|
|
1088
|
+
const req = makeRequestFromKey(name);
|
|
1089
|
+
return pdpInstance.decide(req);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// ----- UI helpers -----
|
|
1093
|
+
function setStatus(kind, text) {
|
|
1094
|
+
statusEl.classList.remove('ready', 'error');
|
|
1095
|
+
statusDotEl.style.background = '#9ca3af';
|
|
1096
|
+
if (kind === 'ready') {
|
|
1097
|
+
statusEl.classList.add('ready');
|
|
1098
|
+
statusDotEl.style.background = 'var(--accent)';
|
|
1099
|
+
} else if (kind === 'error') {
|
|
1100
|
+
statusEl.classList.add('error');
|
|
1101
|
+
statusDotEl.style.background = 'var(--danger)';
|
|
1102
|
+
}
|
|
1103
|
+
statusTextEl.textContent = text;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function renderScenarioCards() {
|
|
1107
|
+
scenarioListEl.innerHTML = '';
|
|
1108
|
+
scenarios.forEach((s) => {
|
|
1109
|
+
const card = document.createElement('article');
|
|
1110
|
+
card.className = 'card scenario-card';
|
|
1111
|
+
|
|
1112
|
+
const header = document.createElement('div');
|
|
1113
|
+
header.className = 'card-header';
|
|
1114
|
+
|
|
1115
|
+
const name = document.createElement('div');
|
|
1116
|
+
name.className = 'scenario-name';
|
|
1117
|
+
name.textContent = s.label;
|
|
1118
|
+
|
|
1119
|
+
const chip = document.createElement('div');
|
|
1120
|
+
chip.className = 'chip';
|
|
1121
|
+
chip.textContent = s.id;
|
|
1122
|
+
|
|
1123
|
+
header.appendChild(name);
|
|
1124
|
+
header.appendChild(chip);
|
|
1125
|
+
|
|
1126
|
+
const desc = document.createElement('p');
|
|
1127
|
+
desc.className = 'scenario-desc';
|
|
1128
|
+
desc.textContent = s.description;
|
|
1129
|
+
|
|
1130
|
+
const pillGroup = document.createElement('div');
|
|
1131
|
+
pillGroup.className = 'pill-group';
|
|
1132
|
+
s.tags.forEach((t) => {
|
|
1133
|
+
const pill = document.createElement('span');
|
|
1134
|
+
pill.className = 'pill';
|
|
1135
|
+
pill.textContent = t;
|
|
1136
|
+
pillGroup.appendChild(pill);
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
const btn = document.createElement('button');
|
|
1140
|
+
btn.type = 'button';
|
|
1141
|
+
btn.className = 'run-btn';
|
|
1142
|
+
btn.innerHTML = '<span class="btn-icon">▶</span><span class="btn-label">Run scenario</span>';
|
|
1143
|
+
btn.addEventListener('click', () => runScenario(s.id, btn));
|
|
1144
|
+
|
|
1145
|
+
card.appendChild(header);
|
|
1146
|
+
card.appendChild(desc);
|
|
1147
|
+
card.appendChild(pillGroup);
|
|
1148
|
+
card.appendChild(btn);
|
|
1149
|
+
|
|
1150
|
+
scenarioListEl.appendChild(card);
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
async function initDemoIfNeeded() {
|
|
1155
|
+
if (demoReadyPromise) {
|
|
1156
|
+
return demoReadyPromise;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
demoReadyPromise = (async () => {
|
|
1160
|
+
try {
|
|
1161
|
+
setStatus('loading', 'Loading embedded AuroraCare logic…');
|
|
1162
|
+
policies = buildPolicies();
|
|
1163
|
+
pdpInstance = new PDP(policies);
|
|
1164
|
+
resetServices();
|
|
1165
|
+
setStatus('ready', 'AuroraCare logic ready. Tap a scenario to run it.');
|
|
1166
|
+
return pdpInstance;
|
|
1167
|
+
} catch (err) {
|
|
1168
|
+
console.error(err);
|
|
1169
|
+
setStatus('error', 'Error loading AuroraCare demo (see console).');
|
|
1170
|
+
throw err;
|
|
1171
|
+
}
|
|
1172
|
+
})();
|
|
1173
|
+
|
|
1174
|
+
return demoReadyPromise;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
async function runScenario(id, button) {
|
|
1178
|
+
if (isBusy) return;
|
|
1179
|
+
isBusy = true;
|
|
1180
|
+
const originalLabel = button.innerHTML;
|
|
1181
|
+
button.innerHTML = '<span class="btn-icon">⏳</span><span class="btn-label">Running…</span>';
|
|
1182
|
+
button.disabled = true;
|
|
1183
|
+
|
|
1184
|
+
try {
|
|
1185
|
+
await initDemoIfNeeded();
|
|
1186
|
+
const data = runNamedScenario(id);
|
|
1187
|
+
renderResult(id, data);
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
console.error(err);
|
|
1190
|
+
renderError(id, err);
|
|
1191
|
+
} finally {
|
|
1192
|
+
button.innerHTML = originalLabel;
|
|
1193
|
+
button.disabled = false;
|
|
1194
|
+
isBusy = false;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function renderResult(id, data) {
|
|
1199
|
+
const answer = data['Answer'] || 'UNKNOWN';
|
|
1200
|
+
const reason = data['Reason why'] || data['Reason'] || 'No explanation returned.';
|
|
1201
|
+
const checks = data['Check'] || {};
|
|
1202
|
+
const permit = String(answer).toUpperCase() === 'PERMIT';
|
|
1203
|
+
|
|
1204
|
+
resultEl.hidden = false;
|
|
1205
|
+
resultEl.innerHTML = '';
|
|
1206
|
+
|
|
1207
|
+
const header = document.createElement('div');
|
|
1208
|
+
header.className = 'result-header';
|
|
1209
|
+
|
|
1210
|
+
const titleBox = document.createElement('div');
|
|
1211
|
+
titleBox.innerHTML =
|
|
1212
|
+
'<div class="scenario-name">' +
|
|
1213
|
+
id +
|
|
1214
|
+
'</div>' +
|
|
1215
|
+
'<div class="status-line">' +
|
|
1216
|
+
(permit ? 'Request permitted' : 'Request denied') +
|
|
1217
|
+
' by AuroraCare PDP.</div>';
|
|
1218
|
+
|
|
1219
|
+
const pill = document.createElement('div');
|
|
1220
|
+
pill.className = 'answer-pill ' + (permit ? 'answer-permit' : 'answer-deny');
|
|
1221
|
+
pill.textContent = permit ? 'PERMIT' : 'DENY';
|
|
1222
|
+
|
|
1223
|
+
header.appendChild(titleBox);
|
|
1224
|
+
header.appendChild(pill);
|
|
1225
|
+
|
|
1226
|
+
const reasonEl = document.createElement('p');
|
|
1227
|
+
reasonEl.className = 'reason';
|
|
1228
|
+
reasonEl.textContent = reason;
|
|
1229
|
+
|
|
1230
|
+
const details = document.createElement('details');
|
|
1231
|
+
details.open = true;
|
|
1232
|
+
|
|
1233
|
+
const summary = document.createElement('summary');
|
|
1234
|
+
summary.textContent = 'C1–C10 checks';
|
|
1235
|
+
|
|
1236
|
+
const list = document.createElement('div');
|
|
1237
|
+
list.className = 'check-list';
|
|
1238
|
+
|
|
1239
|
+
Object.entries(checks).forEach(([key, value]) => {
|
|
1240
|
+
const row = document.createElement('div');
|
|
1241
|
+
row.className = 'check-row';
|
|
1242
|
+
|
|
1243
|
+
const keySpan = document.createElement('span');
|
|
1244
|
+
keySpan.className = 'check-key';
|
|
1245
|
+
keySpan.textContent = key.replace(/_/g, ' ');
|
|
1246
|
+
|
|
1247
|
+
const valSpan = document.createElement('span');
|
|
1248
|
+
valSpan.textContent = value;
|
|
1249
|
+
|
|
1250
|
+
if (typeof value === 'string') {
|
|
1251
|
+
if (value.startsWith('FAIL')) {
|
|
1252
|
+
valSpan.classList.add('status-error');
|
|
1253
|
+
} else if (value.startsWith('OK')) {
|
|
1254
|
+
valSpan.classList.add('status-ok');
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
row.appendChild(keySpan);
|
|
1259
|
+
row.appendChild(valSpan);
|
|
1260
|
+
list.appendChild(row);
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
details.appendChild(summary);
|
|
1264
|
+
details.appendChild(list);
|
|
1265
|
+
|
|
1266
|
+
resultEl.appendChild(header);
|
|
1267
|
+
resultEl.appendChild(reasonEl);
|
|
1268
|
+
resultEl.appendChild(details);
|
|
1269
|
+
|
|
1270
|
+
resultEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function renderError(id, err) {
|
|
1274
|
+
resultEl.hidden = false;
|
|
1275
|
+
resultEl.innerHTML =
|
|
1276
|
+
'<div class="result-header">' +
|
|
1277
|
+
'<div><div class="scenario-name">' +
|
|
1278
|
+
id +
|
|
1279
|
+
'</div>' +
|
|
1280
|
+
'<div class="status-line status-error">Error running scenario (see browser console for details).</div></div>' +
|
|
1281
|
+
'</div>' +
|
|
1282
|
+
'<p class="reason">The browser could not complete the embedded AuroraCare decision logic.</p>';
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function bootAuroraCareDemo() {
|
|
1286
|
+
renderScenarioCards();
|
|
1287
|
+
initDemoIfNeeded();
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
if (document.readyState === 'loading') {
|
|
1291
|
+
document.addEventListener('DOMContentLoaded', bootAuroraCareDemo);
|
|
1292
|
+
} else {
|
|
1293
|
+
bootAuroraCareDemo();
|
|
1294
|
+
}
|
|
1295
|
+
</script>
|
|
1296
|
+
</body>
|
|
1297
|
+
</html>
|