gdc-common-utils-ts 2.3.13 → 2.3.15
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 +14 -0
- package/dist/examples/consent-access.d.ts +1 -0
- package/dist/examples/consent-access.js +15 -0
- package/dist/models/consent-access.d.ts +2 -0
- package/dist/utils/communication-fhir-r4.js +29 -0
- package/dist/utils/consent.js +56 -11
- package/dist/utils/individual-smart.d.ts +20 -0
- package/dist/utils/individual-smart.js +45 -0
- package/dist/utils/smart-scope.d.ts +58 -0
- package/dist/utils/smart-scope.js +129 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,6 +44,13 @@ It is intentionally not a full backend orchestration layer.
|
|
|
44
44
|
|
|
45
45
|
- FHIR SearchParameter names must use canonical FHIR naming (lowercase, with `-` when defined by FHIR).
|
|
46
46
|
- Never use invented camelCase parameter names for FHIR claims/search keys (example: `Communication.part-of` is valid, `Communication.partOf` is not).
|
|
47
|
+
- FHIR claims use exactly `<ResourceType>.<concrete-parameter>` and never add
|
|
48
|
+
pseudo-path segments. For example, the section/topic claim is
|
|
49
|
+
`Communication.topic`; the native projection may contain
|
|
50
|
+
`Communication.topic.coding`, but `Communication.topic.coding` is not a
|
|
51
|
+
claims key.
|
|
52
|
+
- Schema.org claims are a different vocabulary and preserve the canonical
|
|
53
|
+
Schema.org camelCase property name.
|
|
47
54
|
- Only define custom names when no canonical FHIR SearchParameter exists.
|
|
48
55
|
- `resource.meta.claims` is the canonical project-specific claims container and must be preserved across conversions/transports.
|
|
49
56
|
- `resource.meta.claims` is not part of base FHIR; it is a claims-first extension carried by FHIR-like resources in GDC contracts.
|
|
@@ -52,6 +59,12 @@ It is intentionally not a full backend orchestration layer.
|
|
|
52
59
|
boundary before indexed storage. Existing `resource.meta.claims` take
|
|
53
60
|
precedence so SDK-authored semantics survive transport conversion.
|
|
54
61
|
|
|
62
|
+
For a `Communication` whose attached Bundle is `batch` or `collection`, the
|
|
63
|
+
application section code belongs in `Communication.topic`. A LOINC section code
|
|
64
|
+
is serialized as native FHIR `topic.coding`; it is not inferred from
|
|
65
|
+
`payload.contentCodeableConcept`. A document Bundle instead remains organized
|
|
66
|
+
by its attached `Composition.section` graph.
|
|
67
|
+
|
|
55
68
|
## Identity Continuity
|
|
56
69
|
|
|
57
70
|
For ICA-backed organization activation, the representative/controller proof is
|
|
@@ -134,6 +147,7 @@ read first:
|
|
|
134
147
|
- [`docs/101-CLINICAL-IPS.md`](docs/101-CLINICAL-IPS.md)
|
|
135
148
|
- [`docs/REFERENCE-CLINICAL-IPS-API.md`](docs/REFERENCE-CLINICAL-IPS-API.md)
|
|
136
149
|
- [`docs/101-CONSENT_PERMISSION_TEMPLATES.md`](docs/101-CONSENT_PERMISSION_TEMPLATES.md)
|
|
150
|
+
- [`docs/101-INDIVIDUAL-MEMBER-SMART.md`](docs/101-INDIVIDUAL-MEMBER-SMART.md)
|
|
137
151
|
|
|
138
152
|
Shared batch model for high-frequency clinical measurements:
|
|
139
153
|
|
|
@@ -21,6 +21,7 @@ export declare const EXAMPLE_CONSENT_ACCESS_RULES: Readonly<{
|
|
|
21
21
|
paramedicByJurisdiction: ConsentRule & Partial<Record<ClaimConsent.resourceType, string>>;
|
|
22
22
|
directPhysicianDenyInsideAllowedOrganization: ConsentRule & Partial<Record<ClaimConsent.resourceType, string>>;
|
|
23
23
|
relatedPersonByEmail: ConsentRule & Partial<Record<ClaimConsent.resourceType, string>>;
|
|
24
|
+
relatedPersonClinicalSections: ConsentRule & Partial<Record<ClaimConsent.resourceType, string>>;
|
|
24
25
|
revokedPhysicianEmailConsent: ConsentRule & Partial<Record<ClaimConsent.resourceType, string>>;
|
|
25
26
|
}>;
|
|
26
27
|
export declare const EXAMPLE_CONSENT_PHONE_EXTENSION_PENDING: Readonly<{
|
|
@@ -106,6 +106,21 @@ export const EXAMPLE_CONSENT_ACCESS_RULES = Object.freeze({
|
|
|
106
106
|
actions: [HealthcareBasicSections.PatientSummaryDocument.claim],
|
|
107
107
|
resourceTypes: [ResourceTypesFhirR4.Composition, ResourceTypesFhirR4.DocumentReference],
|
|
108
108
|
}),
|
|
109
|
+
relatedPersonClinicalSections: buildRule({
|
|
110
|
+
identifier: 'urn:uuid:consent-related-person-clinical-sections',
|
|
111
|
+
actorIdentifier: EXAMPLE_EMAIL_RELATED_PERSON,
|
|
112
|
+
actorRole: EXAMPLE_RELATED_PERSON_ROLE,
|
|
113
|
+
purpose: HealthcareConsentPurposes.Treatment,
|
|
114
|
+
actions: [
|
|
115
|
+
HealthcareBasicSections.AllergiesAndIntolerances.attributeValue,
|
|
116
|
+
HealthcareBasicSections.HistoryOfMedicationUse.attributeValue,
|
|
117
|
+
],
|
|
118
|
+
resourceTypes: [
|
|
119
|
+
ResourceTypesFhirR4.Composition,
|
|
120
|
+
ResourceTypesFhirR4.AllergyIntolerance,
|
|
121
|
+
ResourceTypesFhirR4.MedicationStatement,
|
|
122
|
+
],
|
|
123
|
+
}),
|
|
109
124
|
revokedPhysicianEmailConsent: buildRule({
|
|
110
125
|
identifier: 'urn:uuid:consent-physician-email-revoked',
|
|
111
126
|
actorIdentifier: EXAMPLE_EMAIL_PROFESSIONAL,
|
|
@@ -15,6 +15,8 @@ export type ConsentActorDescriptor = Readonly<{
|
|
|
15
15
|
actorKind?: ConsentActorKind;
|
|
16
16
|
email?: string;
|
|
17
17
|
did?: string;
|
|
18
|
+
/** Additional verified identifiers bound to the same actor. */
|
|
19
|
+
aliases?: readonly string[];
|
|
18
20
|
phone?: string;
|
|
19
21
|
organizationDid?: string;
|
|
20
22
|
organizationUrl?: string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Always create JSDoc, do not use strings inline in keys nor values, use types instead, and reuse the data test examples.
|
|
2
2
|
import { CommunicationClaim } from '../models/interoperable-claims/communication-claims.js';
|
|
3
|
+
import { LOINC_SYSTEM_URL } from '../models/clinical-sections.js';
|
|
3
4
|
import { validateFhirResource } from './fhir-validator.js';
|
|
4
5
|
/**
|
|
5
6
|
* Validates a FHIR R4 `Communication` resource through the configured validator pipeline.
|
|
@@ -85,6 +86,9 @@ export function transformCommunicationClaimsToResourceFhirR4(communicationClaims
|
|
|
85
86
|
const category = toStringOrUndefined(claims[CommunicationClaim.Category]);
|
|
86
87
|
if (category)
|
|
87
88
|
resource['category'] = [{ coding: [parseSystemCode(category)] }];
|
|
89
|
+
const topic = toStringOrUndefined(claims[CommunicationClaim.Topic]);
|
|
90
|
+
if (topic)
|
|
91
|
+
resource['topic'] = { coding: [parseTopicCoding(topic)] };
|
|
88
92
|
const subject = toStringOrUndefined(claims[CommunicationClaim.Subject]);
|
|
89
93
|
if (subject)
|
|
90
94
|
resource['subject'] = { reference: subject };
|
|
@@ -127,6 +131,7 @@ export function extractCommunicationClaimsFromResourceFhirR4(resource, options =
|
|
|
127
131
|
const partOfRef = resource?.partOf?.[0]?.reference;
|
|
128
132
|
const noteText = resource?.note?.[0]?.text;
|
|
129
133
|
const categoryCoding = resource?.category?.[0]?.coding?.[0];
|
|
134
|
+
const topicCoding = resource?.topic?.coding?.[0];
|
|
130
135
|
const payloads = resource?.payload || [];
|
|
131
136
|
const referencePayload = payloads.find((payload) => payload.contentReference !== undefined);
|
|
132
137
|
const attachmentPayload = payloads.find((payload) => payload.contentAttachment !== undefined);
|
|
@@ -151,6 +156,14 @@ export function extractCommunicationClaimsFromResourceFhirR4(resource, options =
|
|
|
151
156
|
else if (code)
|
|
152
157
|
claims[CommunicationClaim.Category] = code;
|
|
153
158
|
}
|
|
159
|
+
if (topicCoding) {
|
|
160
|
+
const system = toStringOrUndefined(topicCoding.system);
|
|
161
|
+
const code = toStringOrUndefined(topicCoding.code);
|
|
162
|
+
if (system && code)
|
|
163
|
+
claims[CommunicationClaim.Topic] = formatTopicClaim(system, code);
|
|
164
|
+
else if (code)
|
|
165
|
+
claims[CommunicationClaim.Topic] = code;
|
|
166
|
+
}
|
|
154
167
|
setIf(claims, CommunicationClaim.ContentReference, contentReference);
|
|
155
168
|
if (contentAttachment) {
|
|
156
169
|
setIf(claims, CommunicationClaim.ContentAttachmentData, contentAttachment.data);
|
|
@@ -214,6 +227,22 @@ function parseSystemCode(value) {
|
|
|
214
227
|
}
|
|
215
228
|
return { code: trimmed };
|
|
216
229
|
}
|
|
230
|
+
function parseTopicCoding(value) {
|
|
231
|
+
const coding = parseSystemCode(value);
|
|
232
|
+
if (coding.system?.toUpperCase() === 'LOINC') {
|
|
233
|
+
return { ...coding, system: LOINC_SYSTEM_URL };
|
|
234
|
+
}
|
|
235
|
+
return coding;
|
|
236
|
+
}
|
|
237
|
+
function formatTopicClaim(system, code) {
|
|
238
|
+
const normalizedSystem = system.trim().toLowerCase();
|
|
239
|
+
if (normalizedSystem === LOINC_SYSTEM_URL
|
|
240
|
+
|| normalizedSystem === 'https://loinc.org'
|
|
241
|
+
|| normalizedSystem === 'urn:oid:2.16.840.1.113883.6.1') {
|
|
242
|
+
return `LOINC|${code.trim()}`;
|
|
243
|
+
}
|
|
244
|
+
return `${system.trim()}|${code.trim()}`;
|
|
245
|
+
}
|
|
217
246
|
function toStringOrUndefined(value) {
|
|
218
247
|
if (value === undefined || value === null)
|
|
219
248
|
return undefined;
|
package/dist/utils/consent.js
CHANGED
|
@@ -245,9 +245,30 @@ function splitCsv(value) {
|
|
|
245
245
|
.map((item) => item.trim())
|
|
246
246
|
.filter(Boolean);
|
|
247
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Reads one concrete Consent search-parameter claim from either the internal
|
|
250
|
+
* rule shape (`Consent.action`) or a contextualized FHIR claim
|
|
251
|
+
* (`org.hl7.fhir.api.Consent.action`). Persisted rule sets from both forms are
|
|
252
|
+
* therefore evaluated identically.
|
|
253
|
+
*/
|
|
254
|
+
function readConsentRuleClaim(rule, claim) {
|
|
255
|
+
const source = rule;
|
|
256
|
+
if (source[claim] !== undefined)
|
|
257
|
+
return source[claim];
|
|
258
|
+
const context = String(source['@context'] || '').trim().replace(/\.$/, '');
|
|
259
|
+
if (context && source[`${context}.${claim}`] !== undefined) {
|
|
260
|
+
return source[`${context}.${claim}`];
|
|
261
|
+
}
|
|
262
|
+
return source[`org.hl7.fhir.api.${claim}`];
|
|
263
|
+
}
|
|
248
264
|
function normalizeSectionToken(value) {
|
|
249
265
|
const trimmed = String(value || '').trim();
|
|
250
|
-
if (!trimmed
|
|
266
|
+
if (!trimmed)
|
|
267
|
+
return trimmed;
|
|
268
|
+
const loincPrefixed = trimmed.match(/^loinc:([^|:\s]+)$/i);
|
|
269
|
+
if (loincPrefixed)
|
|
270
|
+
return `loinc|${loincPrefixed[1]}`;
|
|
271
|
+
if (!trimmed.includes('|'))
|
|
251
272
|
return trimmed;
|
|
252
273
|
const [system, code] = trimmed.split('|', 2);
|
|
253
274
|
const normalizedSystem = system
|
|
@@ -405,6 +426,12 @@ export function resolveConsentActor(actor) {
|
|
|
405
426
|
}));
|
|
406
427
|
}
|
|
407
428
|
}
|
|
429
|
+
for (const alias of actor.aliases || []) {
|
|
430
|
+
const normalizedAlias = String(alias || '').trim();
|
|
431
|
+
if (normalizedAlias) {
|
|
432
|
+
directTargets.push(normalizeConsentTarget(normalizedAlias, { actorKind }));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
408
435
|
const phone = normalizePhone(String(actor.phone || ''));
|
|
409
436
|
if (phone) {
|
|
410
437
|
const phoneTarget = normalizeConsentTarget(`tel:${phone}`, { actorKind });
|
|
@@ -453,7 +480,7 @@ export function resolveConsentActor(actor) {
|
|
|
453
480
|
* @param options.now Optional evaluation timestamp.
|
|
454
481
|
*/
|
|
455
482
|
export function isConsentRuleActive(rule, options = {}) {
|
|
456
|
-
if (options.subject && String(rule
|
|
483
|
+
if (options.subject && String(readConsentRuleClaim(rule, ClaimConsent.subject) || '').trim() !== String(options.subject || '').trim()) {
|
|
457
484
|
return false;
|
|
458
485
|
}
|
|
459
486
|
const now = options.now instanceof Date
|
|
@@ -461,8 +488,8 @@ export function isConsentRuleActive(rule, options = {}) {
|
|
|
461
488
|
: options.now
|
|
462
489
|
? new Date(options.now).getTime()
|
|
463
490
|
: Date.now();
|
|
464
|
-
const periodStart = String(rule
|
|
465
|
-
const periodEnd = String(rule
|
|
491
|
+
const periodStart = String(readConsentRuleClaim(rule, ClaimConsent.periodStart) || '').trim();
|
|
492
|
+
const periodEnd = String(readConsentRuleClaim(rule, ClaimConsent.periodEnd) || '').trim();
|
|
466
493
|
if (periodStart && !Number.isNaN(Date.parse(periodStart)) && Date.parse(periodStart) > now)
|
|
467
494
|
return false;
|
|
468
495
|
if (periodEnd && !Number.isNaN(Date.parse(periodEnd)) && Date.parse(periodEnd) < now)
|
|
@@ -472,7 +499,7 @@ export function isConsentRuleActive(rule, options = {}) {
|
|
|
472
499
|
function groupRulesBy(rules, predicate) {
|
|
473
500
|
const groups = {};
|
|
474
501
|
for (const rule of rules) {
|
|
475
|
-
for (const token of splitCsv(rule
|
|
502
|
+
for (const token of splitCsv(readConsentRuleClaim(rule, ClaimConsent.actorIdentifier))) {
|
|
476
503
|
const normalized = normalizeConsentTarget(token, { preferOrganizationDid: true });
|
|
477
504
|
if (!predicate(normalized))
|
|
478
505
|
continue;
|
|
@@ -505,7 +532,7 @@ function normalizeRequestedList(values, wildcard = '*') {
|
|
|
505
532
|
}
|
|
506
533
|
function extractRuleResourceTypes(rule) {
|
|
507
534
|
const candidates = [
|
|
508
|
-
rule
|
|
535
|
+
readConsentRuleClaim(rule, ClaimConsent.resourceType),
|
|
509
536
|
rule['Consent.resource-type'],
|
|
510
537
|
rule['Consent.resource'],
|
|
511
538
|
rule['Consent.data-type'],
|
|
@@ -518,7 +545,7 @@ function extractRuleResourceTypes(rule) {
|
|
|
518
545
|
return [];
|
|
519
546
|
}
|
|
520
547
|
function ruleMatchesRole(rule, actorRole) {
|
|
521
|
-
const ruleRoles = splitCsv(rule
|
|
548
|
+
const ruleRoles = splitCsv(readConsentRuleClaim(rule, ClaimConsent.actorRole)).map(normalizeConsentRoleValue).filter(Boolean);
|
|
522
549
|
if (ruleRoles.length === 0 || ruleRoles.includes('*'))
|
|
523
550
|
return true;
|
|
524
551
|
const requestedRole = normalizeConsentRoleValue(String(actorRole || ''));
|
|
@@ -542,7 +569,7 @@ function ruleMatchesRole(rule, actorRole) {
|
|
|
542
569
|
});
|
|
543
570
|
}
|
|
544
571
|
function ruleMatchesPurpose(rule, purpose) {
|
|
545
|
-
const rulePurpose = String(rule
|
|
572
|
+
const rulePurpose = String(readConsentRuleClaim(rule, ClaimConsent.purpose) || '').trim();
|
|
546
573
|
if (!purpose || !rulePurpose)
|
|
547
574
|
return true;
|
|
548
575
|
return rulePurpose === purpose;
|
|
@@ -551,11 +578,29 @@ function ruleMatchesSection(rule, section) {
|
|
|
551
578
|
if (!section || section === '*')
|
|
552
579
|
return true;
|
|
553
580
|
const requestedSection = normalizeSectionToken(section);
|
|
554
|
-
const
|
|
581
|
+
const rawAction = String(readConsentRuleClaim(rule, ClaimConsent.action) || '').trim();
|
|
582
|
+
const actions = extractRuleSectionTokens(rawAction).map(normalizeSectionToken);
|
|
555
583
|
if (actions.length === 0)
|
|
556
584
|
return false;
|
|
557
585
|
return actions.includes(requestedSection) || actions.includes('*');
|
|
558
586
|
}
|
|
587
|
+
function extractRuleSectionTokens(rawAction) {
|
|
588
|
+
if (!rawAction)
|
|
589
|
+
return [];
|
|
590
|
+
const canonicalExpressions = rawAction
|
|
591
|
+
.split(/\s+/)
|
|
592
|
+
.map((value) => value.trim())
|
|
593
|
+
.filter((value) => /^(?:organization\/|patient\/)?Composition\.[A-Za-z]+(?:\?|$)/i.test(value));
|
|
594
|
+
if (canonicalExpressions.length === 0)
|
|
595
|
+
return splitCsv(rawAction);
|
|
596
|
+
return canonicalExpressions.flatMap((expression) => {
|
|
597
|
+
const [, queryString = ''] = expression.split('?', 2);
|
|
598
|
+
const section = new URLSearchParams(queryString).get('section');
|
|
599
|
+
return section
|
|
600
|
+
? section.split(',').map((value) => value.trim()).filter(Boolean)
|
|
601
|
+
: ['*'];
|
|
602
|
+
});
|
|
603
|
+
}
|
|
559
604
|
function ruleMatchesResourceType(rule, resourceType) {
|
|
560
605
|
if (!resourceType || resourceType === '*')
|
|
561
606
|
return true;
|
|
@@ -565,7 +610,7 @@ function ruleMatchesResourceType(rule, resourceType) {
|
|
|
565
610
|
return resourceTypes.includes(resourceType) || resourceTypes.includes('*');
|
|
566
611
|
}
|
|
567
612
|
function resolveRuleMatch(rule, actor) {
|
|
568
|
-
for (const token of splitCsv(rule
|
|
613
|
+
for (const token of splitCsv(readConsentRuleClaim(rule, ClaimConsent.actorIdentifier))) {
|
|
569
614
|
const normalized = normalizeConsentTarget(token, { preferOrganizationDid: true });
|
|
570
615
|
if (actor.directTargets.some((target) => target.canonicalValue === normalized.canonicalValue)) {
|
|
571
616
|
return { matchKind: 'direct', target: normalized, precedenceBase: 10 };
|
|
@@ -583,7 +628,7 @@ function toRuleMatch(rule, actor, section, resourceType) {
|
|
|
583
628
|
const resolved = resolveRuleMatch(rule, actor);
|
|
584
629
|
if (!resolved.target || resolved.matchKind === 'none' || resolved.precedenceBase === undefined)
|
|
585
630
|
return undefined;
|
|
586
|
-
const decision = rule
|
|
631
|
+
const decision = readConsentRuleClaim(rule, ClaimConsent.decision);
|
|
587
632
|
const precedence = resolved.precedenceBase + (decision === 'deny' ? 0 : 1);
|
|
588
633
|
return {
|
|
589
634
|
rule,
|
|
@@ -38,6 +38,15 @@ export type IndividualControllerCredentialInput = IndividualActorIdentityCredent
|
|
|
38
38
|
export type IndividualControllerVpPayloadInput = IndividualActorIdentityVpPayloadInput;
|
|
39
39
|
export type IndividualMemberCredentialInput = IndividualActorIdentityCredentialInput;
|
|
40
40
|
export type IndividualMemberVpPayloadInput = IndividualActorIdentityVpPayloadInput;
|
|
41
|
+
export type IndividualMemberCredentialSummary = Readonly<{
|
|
42
|
+
actorDid: string;
|
|
43
|
+
subjectDid: string;
|
|
44
|
+
relationship: string;
|
|
45
|
+
issuerDid?: string;
|
|
46
|
+
email?: string;
|
|
47
|
+
telephone?: string;
|
|
48
|
+
sameAs: string[];
|
|
49
|
+
}>;
|
|
41
50
|
export declare function getIndividualControllerIdentitySameAs(input: Readonly<Pick<IndividualControllerCredentialInput, 'sameAs' | 'email'>>): string[];
|
|
42
51
|
export declare function getIndividualControllerIdentityTelephone(input: Readonly<Pick<IndividualControllerCredentialInput, 'telephone'>>): string | undefined;
|
|
43
52
|
export declare function getIndividualControllerIdentityVC(input: IndividualControllerCredentialInput): Record<string, unknown>;
|
|
@@ -51,6 +60,17 @@ export declare function getIndividualSubjectVC(input: IndividualSubjectCredentia
|
|
|
51
60
|
export declare function getIndividualMemberIdentitySameAs(input: Readonly<Pick<IndividualMemberCredentialInput, 'sameAs' | 'email'>>): string[];
|
|
52
61
|
export declare function getIndividualMemberIdentityTelephone(input: Readonly<Pick<IndividualMemberCredentialInput, 'telephone'>>): string | undefined;
|
|
53
62
|
export declare function getIndividualMemberIdentityVC(input: IndividualMemberCredentialInput): Record<string, unknown>;
|
|
63
|
+
/**
|
|
64
|
+
* Reads the relationship identity asserted by one already-verified member VC.
|
|
65
|
+
* Signature/proof verification belongs to the enclosing VP verifier.
|
|
66
|
+
*/
|
|
67
|
+
export declare function summarizeIndividualMemberIdentityCredential(credential: unknown): IndividualMemberCredentialSummary | undefined;
|
|
68
|
+
/** Finds an exact actor/subject member relationship in an already-verified VP. */
|
|
69
|
+
export declare function getMatchingIndividualMemberCredentialFromVpToken(vpToken: string, criteria: Readonly<{
|
|
70
|
+
actorDid: string;
|
|
71
|
+
subjectDid: string;
|
|
72
|
+
relationship?: string;
|
|
73
|
+
}>): IndividualMemberCredentialSummary | undefined;
|
|
54
74
|
export declare function buildIndividualMemberIdentityVpPayload(input: IndividualMemberVpPayloadInput): Record<string, unknown>;
|
|
55
75
|
export declare function buildUnsignedIndividualMemberIdentityVpJwt(input: IndividualMemberVpPayloadInput, options?: Readonly<{
|
|
56
76
|
nowSeconds?: number;
|
|
@@ -2,6 +2,7 @@ import { ClaimsPersonSchemaorg } from '../constants/schemaorg.js';
|
|
|
2
2
|
import { IndividualCredentialTypes, W3cCredentialTypes, } from '../constants/verifiable-credentials.js';
|
|
3
3
|
import { normalizeSameAsHashCsv, normalizeSameAsHashList, normalizeTelephoneHash } from './same-as.js';
|
|
4
4
|
import { buildUnsignedVpJwt } from './jwt.js';
|
|
5
|
+
import { getVpCredentials } from './vp-token.js';
|
|
5
6
|
function getIndividualIdentitySameAs(input) {
|
|
6
7
|
const sameAsCandidates = [];
|
|
7
8
|
if (input.sameAs) {
|
|
@@ -127,6 +128,50 @@ export function getIndividualMemberIdentityTelephone(input) {
|
|
|
127
128
|
export function getIndividualMemberIdentityVC(input) {
|
|
128
129
|
return buildIndividualIdentityVC(input, IndividualCredentialTypes.IndividualMemberCredential);
|
|
129
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Reads the relationship identity asserted by one already-verified member VC.
|
|
133
|
+
* Signature/proof verification belongs to the enclosing VP verifier.
|
|
134
|
+
*/
|
|
135
|
+
export function summarizeIndividualMemberIdentityCredential(credential) {
|
|
136
|
+
const source = credential;
|
|
137
|
+
const types = Array.isArray(source?.type) ? source.type.map(String) : [String(source?.type || '')];
|
|
138
|
+
if (!types.includes(IndividualCredentialTypes.IndividualMemberCredential))
|
|
139
|
+
return undefined;
|
|
140
|
+
const subject = source?.credentialSubject || {};
|
|
141
|
+
const actorDid = String(subject.id || '').trim();
|
|
142
|
+
const subjectDid = String(subject.subject || '').trim();
|
|
143
|
+
const relationship = String(subject.relationship || '').trim();
|
|
144
|
+
if (!actorDid || !subjectDid || !relationship)
|
|
145
|
+
return undefined;
|
|
146
|
+
return {
|
|
147
|
+
actorDid,
|
|
148
|
+
subjectDid,
|
|
149
|
+
relationship,
|
|
150
|
+
...(String(source?.issuer?.id || source?.issuer || '').trim()
|
|
151
|
+
? { issuerDid: String(source.issuer?.id || source.issuer).trim() }
|
|
152
|
+
: {}),
|
|
153
|
+
...(String(subject[ClaimsPersonSchemaorg.email] || '').trim()
|
|
154
|
+
? { email: String(subject[ClaimsPersonSchemaorg.email]).trim().toLowerCase() }
|
|
155
|
+
: {}),
|
|
156
|
+
...(String(subject[ClaimsPersonSchemaorg.telephone] || '').trim()
|
|
157
|
+
? { telephone: String(subject[ClaimsPersonSchemaorg.telephone]).trim() }
|
|
158
|
+
: {}),
|
|
159
|
+
sameAs: normalizeSameAsHashList(subject.sameAs),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** Finds an exact actor/subject member relationship in an already-verified VP. */
|
|
163
|
+
export function getMatchingIndividualMemberCredentialFromVpToken(vpToken, criteria) {
|
|
164
|
+
return getVpCredentials(vpToken)
|
|
165
|
+
.map(summarizeIndividualMemberIdentityCredential)
|
|
166
|
+
.find((summary) => {
|
|
167
|
+
if (!summary)
|
|
168
|
+
return false;
|
|
169
|
+
return summary.actorDid === String(criteria.actorDid || '').trim()
|
|
170
|
+
&& summary.subjectDid === String(criteria.subjectDid || '').trim()
|
|
171
|
+
&& (!criteria.relationship
|
|
172
|
+
|| summary.relationship.toLowerCase() === String(criteria.relationship).trim().toLowerCase());
|
|
173
|
+
});
|
|
174
|
+
}
|
|
130
175
|
export function buildIndividualMemberIdentityVpPayload(input) {
|
|
131
176
|
return buildIndividualIdentityVpPayload(input, IndividualCredentialTypes.IndividualMemberCredential);
|
|
132
177
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ConsentActorDescriptor, EffectiveAccessEvaluation } from '../models/consent-access';
|
|
2
|
+
import type { ConsentRule } from '../models/consent-rule';
|
|
1
3
|
export type SmartCompositionReadScopeOptions = {
|
|
2
4
|
/**
|
|
3
5
|
* Subject DID pinned by the current CORE GW root scope contract.
|
|
@@ -26,3 +28,59 @@ export type SmartCompositionReadScopeOptions = {
|
|
|
26
28
|
* scope builder.
|
|
27
29
|
*/
|
|
28
30
|
export declare function buildSmartCompositionReadScope(options: SmartCompositionReadScopeOptions): string;
|
|
31
|
+
export type DeriveGrantedSmartScopesInput = Readonly<{
|
|
32
|
+
/** Requested SMART root scopes. Every scope must pin the same subject. */
|
|
33
|
+
requestedScopes: string | readonly string[];
|
|
34
|
+
/** Authenticated actor descriptors resolved by a trusted runtime. */
|
|
35
|
+
actor: ConsentActorDescriptor;
|
|
36
|
+
/** Canonical relationship or professional role claim. */
|
|
37
|
+
actorRole?: string;
|
|
38
|
+
/** Consent purpose required for the access. */
|
|
39
|
+
purpose?: string;
|
|
40
|
+
/** Deterministic clock used by tests and policy evaluation. */
|
|
41
|
+
now?: string | Date;
|
|
42
|
+
/**
|
|
43
|
+
* Known sections used to expand `section=*` or an omitted section filter.
|
|
44
|
+
*
|
|
45
|
+
* Defaults to the shared IPS summary-section registry. Supplying an explicit
|
|
46
|
+
* list is useful for a sector-specific profile while keeping the evaluator
|
|
47
|
+
* product-neutral.
|
|
48
|
+
*/
|
|
49
|
+
availableSections?: readonly string[];
|
|
50
|
+
}>;
|
|
51
|
+
export type GrantedSmartScopesDecision = 'granted' | 'partial' | 'denied';
|
|
52
|
+
export type GrantedSmartScopesResult = Readonly<{
|
|
53
|
+
/** One subject shared by every accepted request scope. */
|
|
54
|
+
subject: string;
|
|
55
|
+
/** Overall projection decision. Empty grants are always `denied`. */
|
|
56
|
+
decision: GrantedSmartScopesDecision;
|
|
57
|
+
/** Exact scopes that GW may place in the signed token. */
|
|
58
|
+
grantedScopes: string[];
|
|
59
|
+
/** Original scopes or scope fragments that received no grant. */
|
|
60
|
+
deniedScopes: string[];
|
|
61
|
+
/** Exact clinical sections retained in `grantedScopes`. */
|
|
62
|
+
grantedSections: string[];
|
|
63
|
+
/** Requested clinical sections omitted from `grantedScopes`. */
|
|
64
|
+
deniedSections: string[];
|
|
65
|
+
/** Per-scope shared consent evaluations retained for audit/tests. */
|
|
66
|
+
evaluations: EffectiveAccessEvaluation[];
|
|
67
|
+
}>;
|
|
68
|
+
/**
|
|
69
|
+
* Derives the exact read-only SMART scopes covered by active Consent rules.
|
|
70
|
+
*
|
|
71
|
+
* This pure helper deliberately does not create or sign a JWT. A trusted GW
|
|
72
|
+
* supplies authoritative rules and actor identity, calls this projection, and
|
|
73
|
+
* signs only `grantedScopes`. Browser applications may use the same function
|
|
74
|
+
* with fixtures to predict UI capabilities, but that prediction grants no
|
|
75
|
+
* authority.
|
|
76
|
+
*
|
|
77
|
+
* Security properties:
|
|
78
|
+
* - all root scopes must target one subject
|
|
79
|
+
* - `patient/*`, non-Composition and write-bearing scopes fail closed
|
|
80
|
+
* - wildcard/omitted section requests are expanded to explicit known sections
|
|
81
|
+
* - the returned scopes contain only `requested ∩ actively consented`
|
|
82
|
+
*
|
|
83
|
+
* @param rules Authoritative or test Consent rules.
|
|
84
|
+
* @param input Requested scopes plus trusted actor/purpose context.
|
|
85
|
+
*/
|
|
86
|
+
export declare function deriveGrantedSmartScopes(rules: readonly ConsentRule[], input: DeriveGrantedSmartScopesInput): GrantedSmartScopesResult;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { HealthcareBasicSections } from '../constants/healthcare.js';
|
|
1
|
+
import { HealthcareBasicSections, HealthcareSummarySections, } from '../constants/healthcare.js';
|
|
2
|
+
import { evaluateConsentCoverage } from './consent.js';
|
|
2
3
|
/**
|
|
3
4
|
* Builds the gateway-pinned SMART root scope required by the current CORE GW
|
|
4
5
|
* token contract:
|
|
@@ -27,3 +28,130 @@ export function buildSmartCompositionReadScope(options) {
|
|
|
27
28
|
}
|
|
28
29
|
return `organization/Composition.${options.accessVerb || 'rs'}?${query.toString()}`;
|
|
29
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Derives the exact read-only SMART scopes covered by active Consent rules.
|
|
33
|
+
*
|
|
34
|
+
* This pure helper deliberately does not create or sign a JWT. A trusted GW
|
|
35
|
+
* supplies authoritative rules and actor identity, calls this projection, and
|
|
36
|
+
* signs only `grantedScopes`. Browser applications may use the same function
|
|
37
|
+
* with fixtures to predict UI capabilities, but that prediction grants no
|
|
38
|
+
* authority.
|
|
39
|
+
*
|
|
40
|
+
* Security properties:
|
|
41
|
+
* - all root scopes must target one subject
|
|
42
|
+
* - `patient/*`, non-Composition and write-bearing scopes fail closed
|
|
43
|
+
* - wildcard/omitted section requests are expanded to explicit known sections
|
|
44
|
+
* - the returned scopes contain only `requested ∩ actively consented`
|
|
45
|
+
*
|
|
46
|
+
* @param rules Authoritative or test Consent rules.
|
|
47
|
+
* @param input Requested scopes plus trusted actor/purpose context.
|
|
48
|
+
*/
|
|
49
|
+
export function deriveGrantedSmartScopes(rules, input) {
|
|
50
|
+
const rawScopes = (Array.isArray(input.requestedScopes)
|
|
51
|
+
? input.requestedScopes
|
|
52
|
+
: [input.requestedScopes])
|
|
53
|
+
.map((scope) => String(scope || '').trim())
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
if (rawScopes.length === 0) {
|
|
56
|
+
throw new Error('deriveGrantedSmartScopes requires at least one requested scope.');
|
|
57
|
+
}
|
|
58
|
+
const availableSections = Array.from(new Set((input.availableSections?.length
|
|
59
|
+
? input.availableSections
|
|
60
|
+
: Object.values(HealthcareSummarySections).map((section) => section.attributeValue))
|
|
61
|
+
.map((section) => String(section || '').trim())
|
|
62
|
+
.filter(Boolean)));
|
|
63
|
+
const parsedScopes = rawScopes.map((scope) => parseClinicalScope(scope, availableSections));
|
|
64
|
+
const subjects = Array.from(new Set(parsedScopes.map((scope) => scope.subject).filter(Boolean)));
|
|
65
|
+
if (subjects.length !== 1) {
|
|
66
|
+
throw new Error('deriveGrantedSmartScopes requires every requested scope to pin one single subject.');
|
|
67
|
+
}
|
|
68
|
+
const subject = subjects[0];
|
|
69
|
+
const grantedScopes = [];
|
|
70
|
+
const deniedScopes = [];
|
|
71
|
+
const grantedSections = [];
|
|
72
|
+
const deniedSections = [];
|
|
73
|
+
const evaluations = [];
|
|
74
|
+
for (const parsed of parsedScopes) {
|
|
75
|
+
if (!parsed.supported) {
|
|
76
|
+
deniedScopes.push(parsed.raw);
|
|
77
|
+
deniedSections.push(...parsed.requestedSections);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const scopeGrantedSections = [];
|
|
81
|
+
for (const section of parsed.requestedSections) {
|
|
82
|
+
const evaluation = evaluateConsentCoverage([...rules], {
|
|
83
|
+
subject,
|
|
84
|
+
actor: input.actor,
|
|
85
|
+
actorRole: input.actorRole,
|
|
86
|
+
purpose: input.purpose,
|
|
87
|
+
sections: [section],
|
|
88
|
+
resourceTypes: ['Composition'],
|
|
89
|
+
now: input.now,
|
|
90
|
+
});
|
|
91
|
+
evaluations.push(evaluation);
|
|
92
|
+
if (evaluation.allowed) {
|
|
93
|
+
scopeGrantedSections.push(section);
|
|
94
|
+
grantedSections.push(section);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
deniedSections.push(section);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (scopeGrantedSections.length === 0) {
|
|
101
|
+
deniedScopes.push(parsed.raw);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
grantedScopes.push(buildSmartCompositionReadScope({
|
|
105
|
+
subjectDid: subject,
|
|
106
|
+
sections: scopeGrantedSections,
|
|
107
|
+
accessVerb: parsed.accessVerb,
|
|
108
|
+
}));
|
|
109
|
+
if (scopeGrantedSections.length !== parsed.requestedSections.length) {
|
|
110
|
+
deniedScopes.push(parsed.raw);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const uniqueGrantedScopes = Array.from(new Set(grantedScopes));
|
|
114
|
+
const uniqueDeniedScopes = Array.from(new Set(deniedScopes));
|
|
115
|
+
const uniqueGrantedSections = Array.from(new Set(grantedSections));
|
|
116
|
+
const uniqueDeniedSections = Array.from(new Set(deniedSections))
|
|
117
|
+
.filter((section) => !uniqueGrantedSections.includes(section));
|
|
118
|
+
const decision = uniqueGrantedScopes.length === 0
|
|
119
|
+
? 'denied'
|
|
120
|
+
: uniqueDeniedScopes.length > 0 || uniqueDeniedSections.length > 0
|
|
121
|
+
? 'partial'
|
|
122
|
+
: 'granted';
|
|
123
|
+
return {
|
|
124
|
+
subject,
|
|
125
|
+
decision,
|
|
126
|
+
grantedScopes: uniqueGrantedScopes,
|
|
127
|
+
deniedScopes: uniqueDeniedScopes,
|
|
128
|
+
grantedSections: uniqueGrantedSections,
|
|
129
|
+
deniedSections: uniqueDeniedSections,
|
|
130
|
+
evaluations,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function parseClinicalScope(rawScope, availableSections) {
|
|
134
|
+
const [head, queryString = ''] = rawScope.split('?', 2);
|
|
135
|
+
const match = head.match(/^organization\/Composition\.(r|rs)$/);
|
|
136
|
+
const params = new URLSearchParams(queryString);
|
|
137
|
+
const subject = String(params.get('subject') || '').trim();
|
|
138
|
+
if (!subject) {
|
|
139
|
+
throw new Error(`SMART scope must pin a subject: ${rawScope}`);
|
|
140
|
+
}
|
|
141
|
+
const unsupportedFilter = Array.from(params.keys())
|
|
142
|
+
.some((key) => key !== 'subject' && key !== 'section');
|
|
143
|
+
const requested = String(params.get('section') || '').trim();
|
|
144
|
+
const requestedSections = !requested || requested.split(',').includes('*')
|
|
145
|
+
? [...availableSections]
|
|
146
|
+
: requested
|
|
147
|
+
.split(',')
|
|
148
|
+
.map((section) => section.trim())
|
|
149
|
+
.filter(Boolean);
|
|
150
|
+
return {
|
|
151
|
+
raw: rawScope,
|
|
152
|
+
subject,
|
|
153
|
+
accessVerb: match?.[1],
|
|
154
|
+
requestedSections: Array.from(new Set(requestedSections)),
|
|
155
|
+
supported: Boolean(match) && !unsupportedFilter && requestedSections.length > 0,
|
|
156
|
+
};
|
|
157
|
+
}
|